From 6d7b4ff3f534e80b5cbd41827f141aec53f6668e Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Wed, 27 Jan 2021 20:49:27 -0500 Subject: [PATCH] Metrics There's no description for a commit with changes to 65 files. --- api/beestat.sql | 372 ++++--- api/cora/database.php | 2 +- api/ecobee_thermostat.php | 227 +++-- api/profile.php | 223 ++++- api/temperature_profile.php | 752 --------------- api/thermostat.php | 488 +++++++++- api/thermostat_group.php | 904 ------------------ img/nest/connect.png | Bin 7613 -> 0 bytes img/nest/logo.png | Bin 73821 -> 0 bytes img/nest/logo.svg | 1 - img/waveform.png | Bin 9233 -> 0 bytes js/beestat.js | 5 +- js/beestat/comparisons.js | 123 +-- js/beestat/poll.js | 26 +- js/beestat/requestor.js | 11 - js/beestat/runtime_thermostat.js | 22 +- js/beestat/temperature.js | 1 - js/beestat/time.js | 14 - js/component/card.js | 4 +- js/component/card/alerts.js | 9 +- js/component/card/compare_notification.js | 33 + js/component/card/comparison_settings.js | 276 ++++-- js/component/card/early_access.js | 10 - js/component/card/metrics.js | 198 +++- js/component/card/my_home.js | 53 +- js/component/card/runtime_sensor_detail.js | 33 +- .../card/runtime_thermostat_detail.js | 26 +- js/component/card/score.js | 233 ----- js/component/card/score/cool.js | 26 - js/component/card/score/heat.js | 26 - js/component/card/score/resist.js | 28 - js/component/card/sensors.js | 2 +- js/component/card/system.js | 5 + js/component/card/temperature_profiles.js | 59 +- js/component/card/temperature_profiles_new.js | 237 ----- .../runtime_sensor_detail_temperature.js | 2 +- js/component/chart/temperature_profiles.js | 86 +- .../chart/temperature_profiles_new.js | 346 ------- js/component/metric.js | 479 ++++++++-- js/component/metric/balance_point.js | 43 + js/component/metric/balance_point/heat_1.js | 22 + js/component/metric/balance_point/heat_2.js | 22 + js/component/metric/balance_point/resist.js | 40 + js/component/metric/property.js | 32 + js/component/metric/property/age.js | 50 + js/component/metric/property/square_feet.js | 72 ++ js/component/metric/runtime_per_degree_day.js | 50 + .../metric/runtime_per_degree_day/cool_1.js | 22 + .../metric/runtime_per_degree_day/cool_2.js | 22 + .../metric/runtime_per_degree_day/heat_1.js | 22 + .../metric/runtime_per_degree_day/heat_2.js | 22 + .../metric/runtime_per_heating_degree_day.js | 98 -- js/component/metric/setback.js | 54 ++ js/component/metric/setback/cool.js | 22 + js/component/metric/setback/heat.js | 22 + js/component/metric/setpoint.js | 43 + js/component/metric/setpoint/cool.js | 22 + js/component/metric/setpoint/heat.js | 22 + js/component/metric/setpoint_cool.js | 119 --- js/component/metric/setpoint_heat.js | 119 --- js/component/modal.js | 2 +- js/component/modal/change_system_type.js | 53 +- js/component/modal/change_thermostat.js | 7 +- js/js.php | 35 +- js/layer/load.js | 8 - 65 files changed, 2667 insertions(+), 3720 deletions(-) delete mode 100644 api/temperature_profile.php delete mode 100644 api/thermostat_group.php delete mode 100644 img/nest/connect.png delete mode 100644 img/nest/logo.png delete mode 100644 img/nest/logo.svg delete mode 100644 img/waveform.png create mode 100644 js/component/card/compare_notification.js delete mode 100644 js/component/card/score.js delete mode 100644 js/component/card/score/cool.js delete mode 100644 js/component/card/score/heat.js delete mode 100644 js/component/card/score/resist.js delete mode 100644 js/component/card/temperature_profiles_new.js delete mode 100644 js/component/chart/temperature_profiles_new.js create mode 100644 js/component/metric/balance_point.js create mode 100644 js/component/metric/balance_point/heat_1.js create mode 100644 js/component/metric/balance_point/heat_2.js create mode 100644 js/component/metric/balance_point/resist.js create mode 100644 js/component/metric/property.js create mode 100644 js/component/metric/property/age.js create mode 100644 js/component/metric/property/square_feet.js create mode 100644 js/component/metric/runtime_per_degree_day.js create mode 100644 js/component/metric/runtime_per_degree_day/cool_1.js create mode 100644 js/component/metric/runtime_per_degree_day/cool_2.js create mode 100644 js/component/metric/runtime_per_degree_day/heat_1.js create mode 100644 js/component/metric/runtime_per_degree_day/heat_2.js delete mode 100644 js/component/metric/runtime_per_heating_degree_day.js create mode 100644 js/component/metric/setback.js create mode 100644 js/component/metric/setback/cool.js create mode 100644 js/component/metric/setback/heat.js create mode 100644 js/component/metric/setpoint.js create mode 100644 js/component/metric/setpoint/cool.js create mode 100644 js/component/metric/setpoint/heat.js delete mode 100644 js/component/metric/setpoint_cool.js delete mode 100644 js/component/metric/setpoint_heat.js diff --git a/api/beestat.sql b/api/beestat.sql index 6cfdd8c..7f44259 100644 --- a/api/beestat.sql +++ b/api/beestat.sql @@ -1,8 +1,16 @@ --- MySQL dump 10.13 Distrib 8.0.18, for Linux (x86_64) +-- mysqldump -u root -p --opt beestat -d --single-transaction | sed 's/ AUTO_INCREMENT=[0-9]*//g' > beestat.sql + + + + + + + +-- MySQL dump 10.13 Distrib 8.0.20, for Linux (x86_64) -- -- Host: localhost Database: beestat -- ------------------------------------------------------ --- Server version 8.0.18 +-- Server version 8.0.20 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -23,8 +31,8 @@ DROP TABLE IF EXISTS `address`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `address` ( - `address_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, + `address_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `normalized` json DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -43,7 +51,7 @@ DROP TABLE IF EXISTS `announcement`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `announcement` ( - `announcement_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `announcement_id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `important` tinyint(1) NOT NULL DEFAULT '0', `title` varchar(255) COLLATE utf8_unicode_ci NOT NULL, @@ -62,14 +70,11 @@ DROP TABLE IF EXISTS `api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_cache` ( - `api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, + `api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `expires_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `request_resource` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `request_method` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `request_arguments` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, `response_data` json DEFAULT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`api_cache_id`), @@ -86,26 +91,30 @@ DROP TABLE IF EXISTS `api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_log` ( - `api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `request_ip` int(10) unsigned NOT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - `request_api_user_id` int(10) unsigned DEFAULT NULL, - `request_timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `request_resource` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `request_method` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `request_arguments` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, - `response_error_code` int(10) unsigned DEFAULT NULL, - `response_data` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, - `response_time` decimal(10,4) unsigned DEFAULT NULL, - `response_query_count` int(10) unsigned DEFAULT NULL, - `response_query_time` decimal(10,4) unsigned DEFAULT NULL, - `from_cache` tinyint(1) unsigned DEFAULT '0', - PRIMARY KEY (`api_log_id`), + `api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, + `ip_address` int unsigned NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `request` json DEFAULT NULL, + `response` json DEFAULT NULL, + `error_code` int unsigned DEFAULT NULL, + `error_detail` json DEFAULT NULL, + `total_time` decimal(10,4) unsigned DEFAULT NULL, + `query_count` int unsigned DEFAULT NULL, + `query_time` decimal(10,4) unsigned DEFAULT NULL, + PRIMARY KEY (`api_log_id`,`timestamp`), KEY `user_id` (`user_id`), - KEY `request_ip_request_timestamp` (`request_ip`,`request_timestamp`), - KEY `request_timestamp` (`request_timestamp`), - KEY `request_api_user_id_request_timestamp` (`request_api_user_id`,`request_timestamp`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + KEY `request_ip_request_timestamp` (`ip_address`,`timestamp`), + KEY `request_timestamp` (`timestamp`), + KEY `request_api_user_id_request_timestamp` (`api_user_id`,`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED +/*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) +(PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -116,7 +125,7 @@ DROP TABLE IF EXISTS `api_user`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_user` ( - `api_user_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `api_user_id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `api_key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `session_key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -136,7 +145,7 @@ DROP TABLE IF EXISTS `ecobee_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ecobee_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, @@ -154,16 +163,17 @@ DROP TABLE IF EXISTS `ecobee_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_api_log` ( - `ecobee_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `ecobee_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, - `response` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, + `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`ecobee_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -175,10 +185,10 @@ DROP TABLE IF EXISTS `ecobee_sensor`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_sensor` ( - `ecobee_sensor_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ecobee_sensor_id` int unsigned NOT NULL AUTO_INCREMENT, `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned NOT NULL, - `ecobee_thermostat_id` int(10) unsigned NOT NULL, + `user_id` int unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned NOT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `type` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `code` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -202,10 +212,10 @@ DROP TABLE IF EXISTS `ecobee_thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_thermostat` ( - `ecobee_thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `guid` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `guid` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, + `user_id` int unsigned NOT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `connected` tinyint(1) DEFAULT NULL, `thermostat_revision` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -253,10 +263,10 @@ DROP TABLE IF EXISTS `ecobee_token`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_token` ( - `ecobee_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `access_token` char(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `refresh_token` char(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `ecobee_token_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `access_token` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `refresh_token` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`ecobee_token_id`), @@ -266,41 +276,42 @@ CREATE TABLE `ecobee_token` ( /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `mailchimp_api_cache` +-- Table structure for table `mailgun_api_cache` -- -DROP TABLE IF EXISTS `mailchimp_api_cache`; +DROP TABLE IF EXISTS `mailgun_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `mailchimp_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, +CREATE TABLE `mailgun_api_cache` ( + `mailgun_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`ecobee_api_cache_id`), + PRIMARY KEY (`mailgun_api_cache_id`), KEY `user_id_key` (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `mailchimp_api_log` +-- Table structure for table `mailgun_api_log` -- -DROP TABLE IF EXISTS `mailchimp_api_log`; +DROP TABLE IF EXISTS `mailgun_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `mailchimp_api_log` ( - `mailchimp_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, +CREATE TABLE `mailgun_api_log` ( + `mailgun_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`mailchimp_api_log_id`), + PRIMARY KEY (`mailgun_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -312,12 +323,12 @@ DROP TABLE IF EXISTS `patreon_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `patreon_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`ecobee_api_cache_id`), + PRIMARY KEY (`patreon_api_cache_id`), KEY `user_id_key` (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -330,16 +341,17 @@ DROP TABLE IF EXISTS `patreon_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_api_log` ( - `patreon_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `patreon_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`patreon_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -351,8 +363,8 @@ DROP TABLE IF EXISTS `patreon_token`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_token` ( - `patreon_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, + `patreon_token_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, `access_token` char(43) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `refresh_token` char(43) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -363,6 +375,30 @@ CREATE TABLE `patreon_token` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `runtime_sensor` +-- + +DROP TABLE IF EXISTS `runtime_sensor`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `runtime_sensor` ( + `runtime_sensor_id` bigint unsigned NOT NULL AUTO_INCREMENT, + `sensor_id` int unsigned NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `temperature` smallint DEFAULT NULL, + `occupancy` tinyint unsigned DEFAULT NULL, + PRIMARY KEY (`runtime_sensor_id`,`timestamp`), + UNIQUE KEY `thermostat_id_timestamp` (`sensor_id`,`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED +/*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) +(PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `runtime_thermostat` -- @@ -371,45 +407,44 @@ DROP TABLE IF EXISTS `runtime_thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat` ( - `runtime_thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `thermostat_id` int(10) unsigned NOT NULL, + `runtime_thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `thermostat_id` int unsigned NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `compressor_1` smallint(5) unsigned DEFAULT NULL, - `compressor_2` smallint(5) unsigned DEFAULT NULL, + `compressor_1` smallint unsigned DEFAULT NULL, + `compressor_2` smallint unsigned DEFAULT NULL, `compressor_mode` enum('heat','cool','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `auxiliary_heat_1` smallint(5) unsigned DEFAULT NULL, - `auxiliary_heat_2` smallint(5) unsigned DEFAULT NULL, - `fan` smallint(5) unsigned DEFAULT NULL, - `accessory` smallint(5) unsigned DEFAULT NULL, + `auxiliary_heat_1` smallint unsigned DEFAULT NULL, + `auxiliary_heat_2` smallint unsigned DEFAULT NULL, + `fan` smallint unsigned DEFAULT NULL, + `accessory` smallint unsigned DEFAULT NULL, `accessory_type` enum('humidifier','dehumidifier','ventilator','economizer','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_mode` enum('auto','auxiliary_heat','cool','heat','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `indoor_temperature` smallint(6) DEFAULT NULL, - `indoor_humidity` tinyint(3) unsigned DEFAULT NULL, - `outdoor_temperature` smallint(6) DEFAULT NULL, - `outdoor_humidity` tinyint(3) unsigned DEFAULT NULL, - `event_runtime_thermostat_text_id` smallint(5) unsigned DEFAULT NULL, - `climate_runtime_thermostat_text_id` smallint(5) unsigned DEFAULT NULL, - `setpoint_cool` smallint(5) unsigned DEFAULT NULL, - `setpoint_heat` smallint(5) unsigned DEFAULT NULL, + `indoor_temperature` smallint DEFAULT NULL, + `indoor_humidity` tinyint unsigned DEFAULT NULL, + `outdoor_temperature` smallint DEFAULT NULL, + `outdoor_humidity` tinyint unsigned DEFAULT NULL, + `event_runtime_thermostat_text_id` smallint unsigned DEFAULT NULL, + `climate_runtime_thermostat_text_id` smallint unsigned DEFAULT NULL, + `setpoint_cool` smallint unsigned DEFAULT NULL, + `setpoint_heat` smallint unsigned DEFAULT NULL, PRIMARY KEY (`runtime_thermostat_id`,`timestamp`), UNIQUE KEY `thermostat_id_timestamp` (`thermostat_id`,`timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED /*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) -(PARTITION 2018_10 VALUES LESS THAN (1541030400) ENGINE = InnoDB, - PARTITION 2018_11 VALUES LESS THAN (1543622400) ENGINE = InnoDB, - PARTITION 2018_12 VALUES LESS THAN (1546300800) ENGINE = InnoDB, - PARTITION 2019_01 VALUES LESS THAN (1548979200) ENGINE = InnoDB, - PARTITION 2019_02 VALUES LESS THAN (1551398400) ENGINE = InnoDB, - PARTITION 2019_03 VALUES LESS THAN (1554076800) ENGINE = InnoDB, - PARTITION 2019_04 VALUES LESS THAN (1556668800) ENGINE = InnoDB, - PARTITION 2019_05 VALUES LESS THAN (1559347200) ENGINE = InnoDB, - PARTITION 2019_06 VALUES LESS THAN (1561939200) ENGINE = InnoDB, - PARTITION 2019_07 VALUES LESS THAN (1564617600) ENGINE = InnoDB, - PARTITION 2019_08 VALUES LESS THAN (1567296000) ENGINE = InnoDB, - PARTITION 2019_09 VALUES LESS THAN (1569888000) ENGINE = InnoDB, - PARTITION 2019_10 VALUES LESS THAN (1572566400) ENGINE = InnoDB, - PARTITION 2019_11 VALUES LESS THAN (1575158400) ENGINE = InnoDB, - PARTITION 2019_12 VALUES LESS THAN (1577836800) ENGINE = InnoDB) */; +(PARTITION 2020_01 VALUES LESS THAN (1580515200) ENGINE = InnoDB, + PARTITION 2020_02 VALUES LESS THAN (1583020800) ENGINE = InnoDB, + PARTITION 2020_03 VALUES LESS THAN (1585699200) ENGINE = InnoDB, + PARTITION 2020_04 VALUES LESS THAN (1588291200) ENGINE = InnoDB, + PARTITION 2020_05 VALUES LESS THAN (1590969600) ENGINE = InnoDB, + PARTITION 2020_06 VALUES LESS THAN (1593561600) ENGINE = InnoDB, + PARTITION 2020_07 VALUES LESS THAN (1596240000) ENGINE = InnoDB, + PARTITION 2020_08 VALUES LESS THAN (1598918400) ENGINE = InnoDB, + PARTITION 2020_09 VALUES LESS THAN (1601510400) ENGINE = InnoDB, + PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -420,28 +455,28 @@ DROP TABLE IF EXISTS `runtime_thermostat_summary`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat_summary` ( - `runtime_thermostat_summary_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `thermostat_id` int(10) unsigned NOT NULL, + `runtime_thermostat_summary_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `thermostat_id` int unsigned NOT NULL, `date` date NOT NULL, - `count` smallint(5) unsigned NOT NULL, - `sum_compressor_cool_1` mediumint(8) unsigned NOT NULL, - `sum_compressor_cool_2` mediumint(8) unsigned NOT NULL, - `sum_compressor_heat_1` mediumint(8) unsigned NOT NULL, - `sum_compressor_heat_2` mediumint(8) unsigned NOT NULL, - `sum_auxiliary_heat_1` mediumint(8) unsigned NOT NULL, - `sum_auxiliary_heat_2` mediumint(8) unsigned NOT NULL, - `sum_fan` mediumint(8) unsigned NOT NULL, - `sum_humidifier` mediumint(8) unsigned NOT NULL, - `sum_dehumidifier` mediumint(8) unsigned NOT NULL, - `sum_ventilator` mediumint(8) unsigned NOT NULL, - `sum_economizer` mediumint(8) unsigned NOT NULL, - `avg_outdoor_temperature` smallint(6) NOT NULL, - `avg_outdoor_humidity` tinyint(3) unsigned NOT NULL, - `min_outdoor_temperature` smallint(6) NOT NULL, - `max_outdoor_temperature` smallint(6) NOT NULL, - `avg_indoor_temperature` smallint(6) NOT NULL, - `avg_indoor_humidity` tinyint(3) unsigned NOT NULL, + `count` smallint unsigned NOT NULL, + `sum_compressor_cool_1` mediumint unsigned NOT NULL, + `sum_compressor_cool_2` mediumint unsigned NOT NULL, + `sum_compressor_heat_1` mediumint unsigned NOT NULL, + `sum_compressor_heat_2` mediumint unsigned NOT NULL, + `sum_auxiliary_heat_1` mediumint unsigned NOT NULL, + `sum_auxiliary_heat_2` mediumint unsigned NOT NULL, + `sum_fan` mediumint unsigned NOT NULL, + `sum_humidifier` mediumint unsigned NOT NULL, + `sum_dehumidifier` mediumint unsigned NOT NULL, + `sum_ventilator` mediumint unsigned NOT NULL, + `sum_economizer` mediumint unsigned NOT NULL, + `avg_outdoor_temperature` smallint NOT NULL, + `avg_outdoor_humidity` tinyint unsigned NOT NULL, + `min_outdoor_temperature` smallint NOT NULL, + `max_outdoor_temperature` smallint NOT NULL, + `avg_indoor_temperature` smallint NOT NULL, + `avg_indoor_humidity` tinyint unsigned NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`runtime_thermostat_summary_id`), UNIQUE KEY `thermostat_id_date` (`thermostat_id`,`date`), @@ -459,7 +494,7 @@ DROP TABLE IF EXISTS `runtime_thermostat_text`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat_text` ( - `runtime_thermostat_text_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, + `runtime_thermostat_text_id` smallint unsigned NOT NULL AUTO_INCREMENT, `value` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`runtime_thermostat_text_id`), @@ -475,16 +510,18 @@ DROP TABLE IF EXISTS `sensor`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `sensor` ( - `sensor_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `thermostat_id` int(10) unsigned NOT NULL, - `ecobee_sensor_id` int(10) unsigned DEFAULT NULL, + `sensor_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `thermostat_id` int unsigned NOT NULL, + `ecobee_sensor_id` int unsigned DEFAULT NULL, + `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `type` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `in_use` tinyint(1) DEFAULT NULL, `temperature` decimal(4,1) DEFAULT NULL, - `humidity` int(10) unsigned DEFAULT NULL, + `humidity` int unsigned DEFAULT NULL, `occupancy` tinyint(1) DEFAULT NULL, + `capability` json DEFAULT NULL, `inactive` tinyint(1) NOT NULL DEFAULT '0', `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`sensor_id`), @@ -505,16 +542,16 @@ DROP TABLE IF EXISTS `session`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `session` ( - `session_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `session_id` int unsigned NOT NULL AUTO_INCREMENT, `session_key` char(128) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, - `timeout` int(10) unsigned DEFAULT NULL, - `life` int(10) unsigned DEFAULT NULL, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, + `timeout` int unsigned DEFAULT NULL, + `life` int unsigned DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `created_by` int(10) unsigned NOT NULL, + `created_by` int unsigned NOT NULL, `last_used_at` timestamp NULL DEFAULT NULL, - `last_used_by` int(10) unsigned NOT NULL, + `last_used_by` int unsigned NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`session_id`), UNIQUE KEY `key` (`session_key`), @@ -533,7 +570,7 @@ DROP TABLE IF EXISTS `smarty_streets_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `smarty_streets_api_cache` ( - `smarty_streets_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `smarty_streets_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, @@ -551,16 +588,17 @@ DROP TABLE IF EXISTS `smarty_streets_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `smarty_streets_api_log` ( - `smarty_streets_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `smarty_streets_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`smarty_streets_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -572,34 +610,55 @@ DROP TABLE IF EXISTS `thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `thermostat` ( - `thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `ecobee_thermostat_id` int(10) unsigned DEFAULT NULL, - `thermostat_group_id` int(10) unsigned DEFAULT NULL, - `address_id` int(10) unsigned DEFAULT NULL, + `thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned DEFAULT NULL, + `thermostat_group_id` int unsigned DEFAULT NULL, + `address_id` int unsigned DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `temperature` decimal(4,1) DEFAULT NULL, `temperature_unit` enum('°F','°C') DEFAULT NULL, - `humidity` int(10) unsigned DEFAULT NULL, + `humidity` int unsigned DEFAULT NULL, `alerts` json DEFAULT NULL, `first_connected` timestamp NULL DEFAULT NULL, `sync_begin` timestamp NULL DEFAULT NULL, `sync_end` timestamp NULL DEFAULT NULL, + `data_begin` timestamp NULL DEFAULT NULL, + `data_end` timestamp NULL DEFAULT NULL, `time_zone` varchar(255) DEFAULT NULL, `filters` json DEFAULT NULL, `temperature_profile` json DEFAULT NULL, + `profile` json DEFAULT NULL, `property` json DEFAULT NULL, `system_type` json DEFAULT NULL, + `system_type2` json DEFAULT NULL, `weather` json DEFAULT NULL, - `fan_fixed` timestamp NULL DEFAULT NULL, - `fan_fixed2` timestamp NULL DEFAULT NULL, + `settings` json DEFAULT NULL, + `program` json DEFAULT NULL, + `system_type_heat` enum('geothermal','compressor','boiler','gas','oil','electric','none') DEFAULT NULL, + `system_type_heat_stages` int unsigned DEFAULT NULL, + `system_type_heat_auxiliary` enum('electric','gas','oil','none') DEFAULT NULL, + `system_type_heat_auxiliary_stages` int unsigned DEFAULT NULL, + `system_type_cool` enum('geothermal','compressor','none') DEFAULT NULL, + `system_type_cool_stages` int unsigned DEFAULT NULL, + `property_age` int unsigned DEFAULT NULL, + `property_square_feet` int unsigned DEFAULT NULL, + `property_stories` int unsigned DEFAULT NULL, + `property_structure_type` enum('detached','apartment','condominium','loft','multiplex','townhouse','semi-detached') DEFAULT NULL, + `address_latitude` decimal(8,6) DEFAULT NULL, + `address_longitude` decimal(9,6) DEFAULT NULL, `inactive` tinyint(1) NOT NULL DEFAULT '0', `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`thermostat_id`), KEY `ecobee_thermostat_id` (`ecobee_thermostat_id`), KEY `user_id` (`user_id`), KEY `thermostat_group_id` (`thermostat_group_id`), - KEY `address_id` (`address_id`) + KEY `address_id` (`address_id`), + KEY `comparison` (`system_type_heat`,`system_type_cool`,`system_type_heat_stages`,`system_type_cool_stages`,`property_structure_type`,`address_latitude`,`address_longitude`), + CONSTRAINT `thermostat_ibfk_1` FOREIGN KEY (`ecobee_thermostat_id`) REFERENCES `ecobee_thermostat` (`ecobee_thermostat_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_3` FOREIGN KEY (`thermostat_group_id`) REFERENCES `thermostat_group` (`thermostat_group_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_4` FOREIGN KEY (`address_id`) REFERENCES `address` (`address_id`) ON DELETE RESTRICT ON UPDATE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; @@ -611,19 +670,20 @@ DROP TABLE IF EXISTS `thermostat_group`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `thermostat_group` ( - `thermostat_group_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `address_id` int(10) unsigned NOT NULL, + `thermostat_group_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `address_id` int unsigned NOT NULL, `system_type_heat` enum('geothermal','compressor','boiler','gas','oil','electric','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_type_heat_auxiliary` enum('electric','gas','oil','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_type_cool` enum('geothermal','compressor','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `property_age` int(10) unsigned DEFAULT NULL, - `property_square_feet` int(10) unsigned DEFAULT NULL, - `property_stories` int(10) unsigned DEFAULT NULL, + `property_age` int unsigned DEFAULT NULL, + `property_square_feet` int unsigned DEFAULT NULL, + `property_stories` int unsigned DEFAULT NULL, `property_structure_type` enum('detached','apartment','condominium','loft','multiplex','townhouse','semi-detached') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `address_latitude` decimal(10,8) DEFAULT NULL, `address_longitude` decimal(11,8) DEFAULT NULL, `temperature_profile` json DEFAULT NULL, + `profile` json DEFAULT NULL, `weather` json DEFAULT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`thermostat_group_id`), @@ -644,13 +704,15 @@ DROP TABLE IF EXISTS `user`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `user` ( - `user_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `password` char(60) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `anonymous` tinyint(1) NOT NULL, + `email_address` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, `settings` json DEFAULT NULL, `patreon_status` json DEFAULT NULL, `sync_status` json DEFAULT NULL, + `debug` tinyint(1) DEFAULT '0', `comment` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `deleted` tinyint(1) NOT NULL DEFAULT '0', @@ -668,4 +730,4 @@ CREATE TABLE `user` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2019-12-09 14:00:17 +-- Dump completed on 2021-01-23 19:10:19 diff --git a/api/cora/database.php b/api/cora/database.php index 6891ad5..f473528 100644 --- a/api/cora/database.php +++ b/api/cora/database.php @@ -292,7 +292,7 @@ final class database extends \mysqli { * @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) { + public function column_equals_value_where($column, $value) { if($value === null) { return $this->escape_identifier($column) . ' is null'; } diff --git a/api/ecobee_thermostat.php b/api/ecobee_thermostat.php index 5c8ebe6..dd824a4 100644 --- a/api/ecobee_thermostat.php +++ b/api/ecobee_thermostat.php @@ -212,39 +212,42 @@ class ecobee_thermostat extends cora\crud { $attributes['property'] = $this->get_property($thermostat, $ecobee_thermostat); $attributes['filters'] = $this->get_filters($thermostat, $ecobee_thermostat); $attributes['weather'] = $this->get_weather($thermostat, $ecobee_thermostat); + $attributes['settings'] = $this->get_settings($thermostat, $ecobee_thermostat); $attributes['time_zone'] = $this->get_time_zone($thermostat, $ecobee_thermostat); + $attributes['program'] = $this->get_program($thermostat, $ecobee_thermostat); - $detected_system_type = $this->get_detected_system_type($thermostat, $ecobee_thermostat); - if($thermostat['system_type'] === null) { - $attributes['system_type'] = [ + $detected_system_type2 = $this->get_detected_system_type2($thermostat, $ecobee_thermostat); + if($thermostat['system_type2'] === null) { + $attributes['system_type2'] = [ 'reported' => [ - 'heat' => null, - 'heat_auxiliary' => null, - 'cool' => null + 'heat' => [ + 'equipment' => null, + 'stages' => null + ], + 'heat_auxiliary' => [ + 'equipment' => null, + 'stages' => null + ], + 'cool' => [ + 'equipment' => null, + 'stages' => null + ] ], - 'detected' => $detected_system_type + 'detected' => $detected_system_type2 ]; } else { - $attributes['system_type'] = [ - 'reported' => $thermostat['system_type']['reported'], - 'detected' => $detected_system_type + $attributes['system_type2'] = [ + 'reported' => $thermostat['system_type2']['reported'], + 'detected' => $detected_system_type2 ]; } $attributes['alerts'] = $this->get_alerts( $thermostat, $ecobee_thermostat, - $attributes['system_type'] + $attributes['system_type2'] ); - $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', @@ -255,16 +258,6 @@ class ecobee_thermostat extends cora\crud { ) ] ); - - // 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'] - ] - ); } // Update the email_address on the user. @@ -426,7 +419,7 @@ class ecobee_thermostat extends cora\crud { if(isset($ecobee_thermostat['house_details']['size']) === true) { $square_feet = $ecobee_thermostat['house_details']['size']; if(ctype_digit((string) $square_feet) === true && $square_feet > 0) { - $property['square_feet'] = (int) $square_feet; + $property['square_feet'] = round($square_feet / 500) * 500; } } @@ -559,14 +552,14 @@ class ecobee_thermostat extends cora\crud { // Has heat or cool if($system_type['reported']['heat'] !== null) { - $system_type_heat = $system_type['reported']['heat']; + $system_type_heat = $system_type['reported']['heat']['equipment']; } else { - $system_type_heat = $system_type['detected']['heat']; + $system_type_heat = $system_type['detected']['heat']['equipment']; } if($system_type['reported']['heat_auxiliary'] !== null) { - $system_type_heat_auxiliary = $system_type['reported']['heat_auxiliary']; + $system_type_heat_auxiliary = $system_type['reported']['heat_auxiliary']['equipment']; } else { - $system_type_heat_auxiliary = $system_type['detected']['heat_auxiliary']; + $system_type_heat_auxiliary = $system_type['detected']['heat_auxiliary']['equipment']; } $has_heat = ( $system_type_heat !== 'none' || @@ -574,9 +567,9 @@ class ecobee_thermostat extends cora\crud { ); if($system_type['reported']['cool'] !== null) { - $system_type_cool = $system_type['reported']['cool']; + $system_type_cool = $system_type['reported']['cool']['equipment']; } else { - $system_type_cool = $system_type['detected']['cool']; + $system_type_cool = $system_type['detected']['cool']['equipment']; } $has_cool = ($system_type_cool !== 'none'); @@ -653,42 +646,6 @@ class ecobee_thermostat extends cora\crud { 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. * @@ -697,7 +654,7 @@ class ecobee_thermostat extends cora\crud { * * @return array System type for each of heat, cool, and aux. */ - private function get_detected_system_type($thermostat, $ecobee_thermostat) { + private function get_detected_system_type2($thermostat, $ecobee_thermostat) { $detected_system_type = []; $settings = $ecobee_thermostat['settings']; @@ -716,12 +673,16 @@ class ecobee_thermostat extends cora\crud { } // Heat + $detected_system_type['heat'] = [ + 'equipment' => null, + 'stages' => null + ]; if($settings['heatPumpGroundWater'] === true) { - $detected_system_type['heat'] = 'geothermal'; + $detected_system_type['heat']['equipment'] = 'geothermal'; } else if($settings['hasHeatPump'] === true) { - $detected_system_type['heat'] = 'compressor'; + $detected_system_type['heat']['equipment'] = 'compressor'; } else if($settings['hasBoiler'] === true) { - $detected_system_type['heat'] = 'boiler'; + $detected_system_type['heat']['equipment'] = '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. @@ -731,39 +692,67 @@ class ecobee_thermostat extends cora\crud { isset($address['normalized']['metadata']['latitude']) === true && $address['normalized']['metadata']['latitude'] > 30 ) { - $detected_system_type['heat'] = 'gas'; + $detected_system_type['heat']['equipment'] = 'gas'; } else { - $detected_system_type['heat'] = 'electric'; + $detected_system_type['heat']['equipment'] = 'electric'; } } else { - $detected_system_type['heat'] = 'electric'; + $detected_system_type['heat']['equipment'] = 'electric'; } } else { - $detected_system_type['heat'] = 'none'; + $detected_system_type['heat']['equipment'] = 'none'; } + // Rudimentary aux heat guess. It's pretty good overall but not as good as // heat/cool. + $detected_system_type['heat_auxiliary'] = [ + 'equipment' => null, + 'stages' => null + ]; 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']['equipment'] === 'gas' || + $detected_system_type['heat']['equipment'] === 'boiler' || + $detected_system_type['heat']['equipment'] === 'oil' || + $detected_system_type['heat']['equipment'] === '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; + $detected_system_type['heat_auxiliary']['equipment'] = 'none'; + } else if($detected_system_type['heat']['equipment'] === 'compressor') { + $detected_system_type['heat_auxiliary']['equipment'] = 'electric'; } // Cool + $detected_system_type['cool'] = [ + 'equipment' => null, + 'stages' => null + ]; if($settings['heatPumpGroundWater'] === true) { - $detected_system_type['cool'] = 'geothermal'; + $detected_system_type['cool']['equipment'] = 'geothermal'; } else if(in_array('compressor1', $outputs) === true) { - $detected_system_type['cool'] = 'compressor'; + $detected_system_type['cool']['equipment'] = 'compressor'; } else { - $detected_system_type['cool'] = 'none'; + $detected_system_type['cool']['equipment'] = 'none'; + } + + /** + * Stages. For whatever reason, heat stages seem wrong. They appear to + * match the number of "furnace" stages on the ecobee. Attempt to fix this + * by assuming that if heat and cool are both a compressor then pick the + * max of these two for both. + */ + if( + $detected_system_type['heat']['equipment'] === 'compressor' && + $detected_system_type['cool']['equipment'] === 'compressor' + ) { + $stages = max( + $ecobee_thermostat['settings']['coolStages'], + $ecobee_thermostat['settings']['heatStages'] + ); + $detected_system_type['heat']['stages'] = $stages; + $detected_system_type['cool']['stages'] = $stages; + } else { + $detected_system_type['heat']['stages'] = $ecobee_thermostat['settings']['heatStages']; + $detected_system_type['cool']['stages'] = $ecobee_thermostat['settings']['coolStages']; } return $detected_system_type; @@ -779,7 +768,6 @@ class ecobee_thermostat extends cora\crud { */ private function get_weather($thermostat, $ecobee_thermostat) { $weather = [ - 'station' => null, 'dew_point' => null, 'barometric_pressure' => null, 'humidity_relative' => null, @@ -791,10 +779,6 @@ class ecobee_thermostat extends cora\crud { 'condition' => null ]; - if(isset($ecobee_thermostat['weather']['weatherStation']) === true) { - $weather['station'] = $ecobee_thermostat['weather']['weatherStation']; - } - if( isset($ecobee_thermostat['weather']['forecasts']) === true && isset($ecobee_thermostat['weather']['forecasts'][0]) === true @@ -910,6 +894,55 @@ class ecobee_thermostat extends cora\crud { return $weather; } + /** + * Get certain settings. + * + * @param array $thermostat + * @param array $ecobee_thermostat + * + * @return array + */ + private function get_settings($thermostat, $ecobee_thermostat) { + $settings = []; + + if(isset($ecobee_thermostat['settings']['stage1CoolingDifferentialTemp']) === true) { + $settings['differential_cool'] = ($ecobee_thermostat['settings']['stage1CoolingDifferentialTemp'] / 10); + } + + if(isset($ecobee_thermostat['settings']['stage1HeatingDifferentialTemp']) === true) { + $settings['differential_heat'] = ($ecobee_thermostat['settings']['stage1HeatingDifferentialTemp'] / 10); + } + + return $settings; + } + + /** + * Get program. This is just a clone of the ecobee_thermostat program with a + * slight modification to the temperature values. First, divide by 10 since + * this data is returned directly by the API. Second, round. This is weird + * to do, but as an example one of my comfort settings said "74" in the + * ecobee GUI but "73.5" in the API. After changing it in the GUI the + * fractional amount was removed. I assume this is an old ecobee bug but it + * affects like 10% of thermostats in my database. + * + * @param array $thermostat + * @param array $ecobee_thermostat + * + * @return array + */ + private function get_program($thermostat, $ecobee_thermostat) { + $program = $ecobee_thermostat['program']; + if(isset($program['climates']) === true) { + foreach($program['climates'] as &$climate) { + $climate['coolTemp'] = round($climate['coolTemp'] / 10); + $climate['heatTemp'] = round($climate['heatTemp'] / 10); + } + } + + return $program; + } + + /** * Get the current time zone. It's usually set. If not set use the offset * minutes to find it. Worst case default to the most common time zone. diff --git a/api/profile.php b/api/profile.php index e85cad8..2cc4bad 100644 --- a/api/profile.php +++ b/api/profile.php @@ -14,9 +14,7 @@ class profile extends cora\api { 'public' => [] ]; - public static $cache = [ - 'generate' => 604800 // 7 Days - ]; + public static $cache = []; /** * Generate a profile for the specified thermostat. @@ -118,6 +116,30 @@ class profile extends cora\api { // Get some stuff $thermostat = $this->api('thermostat', 'get', $thermostat_id); + if($thermostat['system_type2']['reported']['heat']['equipment'] !== null) { + $system_type_heat = $thermostat['system_type2']['reported']['heat']['equipment']; + } else { + $system_type_heat = $thermostat['system_type2']['detected']['heat']['equipment']; + } + if($thermostat['system_type2']['reported']['cool']['equipment'] !== null) { + $system_type_cool = $thermostat['system_type2']['reported']['cool']['equipment']; + } else { + $system_type_cool = $thermostat['system_type2']['detected']['cool']['equipment']; + } + + if($thermostat['system_type2']['reported']['heat']['stages'] !== null) { + $heat_stages = $thermostat['system_type2']['reported']['heat']['stages']; + } else { + $heat_stages = $thermostat['system_type2']['detected']['heat']['stages']; + } + if($thermostat['system_type2']['reported']['cool']['stages'] !== null) { + $cool_stages = $thermostat['system_type2']['reported']['cool']['stages']; + } else { + $cool_stages = $thermostat['system_type2']['detected']['cool']['stages']; + } + + + // Figure out all the starting and ending times. Round begin/end to the // nearest 5 minutes to help with the looping later on. $end_timestamp = time(); @@ -132,7 +154,7 @@ class profile extends cora\api { 'read', [ 'attributes' => [ - 'thermostat_group_id' => $thermostat['thermostat_group_id'], + 'address_id' => $thermostat['address_id'], 'inactive' => 0 ] ] @@ -185,7 +207,7 @@ class profile extends cora\api { 'cool_1' => 0, 'cool_2' => 0 ]; - $degree_days_baseline = 65; + $degree_days_base_temperature = 65; $degree_days = []; $begin_runtime = []; @@ -232,7 +254,7 @@ class profile extends cora\api { // Degree days if($date !== $degree_days_date) { - $degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_baseline; + $degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_base_temperature; $degree_days_date = $date; $degree_days_temperatures = []; } @@ -700,40 +722,40 @@ class profile extends cora\api { 'heat' => null, 'cool' => null ], + 'differential' => [ + 'heat' => null, + 'cool' => null + ], + 'setback' => [ + 'heat' => null, + 'cool' => null + ], 'runtime' => [ - 'heat_1' => round($runtime_seconds['heat_1'] / 3600), - 'heat_2' => round($runtime_seconds['heat_2'] / 3600), - 'auxiliary_heat_1' => round($runtime_seconds['auxiliary_heat_1'] / 3600), - 'auxiliary_heat_2' => round($runtime_seconds['auxiliary_heat_2'] / 3600), - 'cool_1' => round($runtime_seconds['cool_1'] / 3600), - 'cool_2' => round($runtime_seconds['cool_2'] / 3600), + 'heat_1' => round($runtime_seconds['heat_1'] / 60), + 'heat_2' => round($runtime_seconds['heat_2'] / 60), + 'auxiliary_heat_1' => round($runtime_seconds['auxiliary_heat_1'] / 60), + 'auxiliary_heat_2' => round($runtime_seconds['auxiliary_heat_2'] / 60), + 'cool_1' => round($runtime_seconds['cool_1'] / 60), + 'cool_2' => round($runtime_seconds['cool_2'] / 60), + ], + 'runtime_per_degree_day' => [ + 'heat_1' => null, + 'heat_2' => null, + 'cool_1' => null, + 'cool_2' => null + ], + 'balance_point' => [ + 'heat_1' => null, + 'heat_2' => null, + 'resist' => null + ], + 'property' => [ + 'age' => null, + 'square_feet' => null ], 'metadata' => [ 'generated_at' => date('c'), 'duration' => round((time() - strtotime($first_timestamp)) / 86400), - 'temperature' => [ - 'heat_1' => [ - 'deltas' => [] - ], - 'heat_2' => [ - 'deltas' => [] - ], - 'auxiliary_heat_1' => [ - 'deltas' => [] - ], - 'auxiliary_heat_2' => [ - 'deltas' => [] - ], - 'cool_1' => [ - 'deltas' => [] - ], - 'cool_2' => [ - 'deltas' => [] - ], - 'resist' => [ - 'deltas' => [] - ] - ] ] ]; @@ -748,7 +770,6 @@ class profile extends cora\api { count($data['deltas_per_hour']) >= $required_samples ) { $deltas[$type][$outdoor_temperature] = round(array_median($data['deltas_per_hour']) / 10, 2); - $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] = count($data['deltas_per_hour']); } } } @@ -766,19 +787,28 @@ class profile extends cora\api { ]; } - foreach(['heat', 'cool'] as $type) { - if(count($setpoints[$type]) > 0) { - $profile['setpoint'][$type] = round(array_mean($setpoints[$type])) / 10; - $profile['metadata']['setpoint'][$type]['samples'] = count($setpoints[$type]); - } + if( + $system_type_cool !== null && + $system_type_cool !== 'none' && + count($setpoints['cool']) > 0 + ) { + $profile['setpoint']['cool'] = round(array_mean($setpoints['cool'])) / 10; + } + + if( + $system_type_heat !== null && + $system_type_heat !== 'none' && + count($setpoints['heat']) > 0 + ) { + $profile['setpoint']['heat'] = round(array_mean($setpoints['heat'])) / 10; } // Heating and cooling degree days. foreach($degree_days as $degree_day) { if($degree_day < 0) { - $profile['degree_days']['cool'] += ($degree_day * -1); + $profile['degree_days']['heat'] += ($degree_day * -1); } else { - $profile['degree_days']['heat'] += ($degree_day); + $profile['degree_days']['cool'] += ($degree_day); } } if ($profile['degree_days']['cool'] !== null) { @@ -788,6 +818,113 @@ class profile extends cora\api { $profile['degree_days']['heat'] = round($profile['degree_days']['heat']); } + // Runtime per degree day + if($profile['degree_days']['heat'] !== null) { + if( + $system_type_heat !== null && + $system_type_heat !== 'none' + ) { + $profile['runtime_per_degree_day']['heat_1'] = round($profile['runtime']['heat_1'] / $profile['degree_days']['heat'], 2); + if($heat_stages === 2) { + $profile['runtime_per_degree_day']['heat_2'] = round($profile['runtime']['heat_2'] / $profile['degree_days']['heat'], 2); + } + } + } + + if($profile['degree_days']['cool'] !== null) { + if( + $system_type_cool !== null && + $system_type_cool !== 'none' + ) { + $profile['runtime_per_degree_day']['cool_1'] = round($profile['runtime']['cool_1'] / $profile['degree_days']['cool'], 2); + if($cool_stages === 2) { + $profile['runtime_per_degree_day']['cool_2'] = round($profile['runtime']['cool_2'] / $profile['degree_days']['cool'], 2); + } + } + } + + // Balance point + if($system_type_heat === 'compressor') { + if( + $profile['temperature']['heat_1'] !== null && + $profile['temperature']['heat_1']['linear_trendline'] !== null + ) { + $linear_trendline = $profile['temperature']['heat_1']['linear_trendline']; + if($linear_trendline['slope'] > 0) { + $profile['balance_point']['heat_1'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1); + } + } + + if( + $profile['temperature']['heat_2'] !== null && + $profile['temperature']['heat_2']['linear_trendline'] !== null + ) { + $linear_trendline = $profile['temperature']['heat_2']['linear_trendline']; + if($linear_trendline['slope'] > 0) { + $profile['balance_point']['heat_2'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1); + } + } + } + + if( + $profile['temperature']['resist'] !== null && + $profile['temperature']['resist']['linear_trendline'] !== null + ) { + $linear_trendline = $profile['temperature']['resist']['linear_trendline']; + if($linear_trendline['slope'] > 0) { + $profile['balance_point']['resist'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1); + } + } + + // Differential + if(isset($thermostat['settings']['differential_heat']) === true) { + $profile['differential']['heat'] = $thermostat['settings']['differential_heat']; + } + + if(isset($thermostat['settings']['differential_cool']) === true) { + $profile['differential']['cool'] = $thermostat['settings']['differential_cool']; + } + + // Setback + if(isset($thermostat['program']['climates']) === true) { + foreach($thermostat['program']['climates'] as $climate) { + if($climate['climateRef'] === 'home') { + $temperature_home_cool = $climate['coolTemp']; + $temperature_home_heat = $climate['heatTemp']; + } else if($climate['climateRef'] === 'away') { + $temperature_away_cool = $climate['coolTemp']; + $temperature_away_heat = $climate['heatTemp']; + } + } + } + + if( + $system_type_cool !== null && + $system_type_cool !== 'none' && + isset($temperature_home_cool) === true && + isset($temperature_away_cool) === true && + $temperature_away_cool >= $temperature_home_cool + ) { + $profile['setback']['cool'] = $temperature_away_cool - $temperature_home_cool; + } + + if( + $system_type_heat !== null && + $system_type_heat !== 'none' && + isset($temperature_home_heat) === true && + isset($temperature_away_heat) === true && + $temperature_home_heat >= $temperature_away_heat + ) { + $profile['setback']['heat'] = $temperature_home_heat - $temperature_away_heat; + } + + // Property + if(isset($thermostat['property']['age']) === true) { + $profile['property']['age'] = $thermostat['property']['age']; + } + if(isset($thermostat['property']['square_feet']) === true) { + $profile['property']['square_feet'] = $thermostat['property']['square_feet']; + } return $profile; } diff --git a/api/temperature_profile.php b/api/temperature_profile.php deleted file mode 100644 index ce0a7d3..0000000 --- a/api/temperature_profile.php +++ /dev/null @@ -1,752 +0,0 @@ - [], - 'public' => [] - ]; - - public static $cache = [ - 'generate' => 604800 // 7 Days - ]; - - /** - * Generate a temperature profile for the specified thermostat. - * - * @param int $thermostat_id - * - * @return array - */ - public function generate($thermostat_id) { - set_time_limit(0); - - // Make sure the thermostat_id provided is one of yours since there's no - // user_id security on the runtime_thermostat table. - $thermostats = $this->api('thermostat', 'read_id'); - if (isset($thermostats[$thermostat_id]) === false) { - throw new Exception('Invalid thermostat_id.', 10300); - } - - /** - * 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 event_runtime_thermostat_text_id 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 - * event_runtime_thermostat_text_id 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); - - // Figure out all the starting and ending times. Round begin/end to the - // nearest 5 minutes to help with the looping later on. - $end_timestamp = time(); - $begin_timestamp = strtotime('-1 year', $end_timestamp); - - // 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 - $thermostat_ids = array_column($group_thermostats, '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($thermostat_ids))); - - $chunk_size = $days * 86400; - - if($chunk_size === 0) { - throw new Exception('Too many thermostats; cannot generate temperature profile.', 10301); - } - - $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`, - `thermostat_id`, - `indoor_temperature`, - `outdoor_temperature`, - `compressor_1`, - `compressor_2`, - `compressor_mode`, - `auxiliary_heat_1`, - `auxiliary_heat_2`, - `event_runtime_thermostat_text_id`, - `climate_runtime_thermostat_text_id` - from - `runtime_thermostat` - where - `thermostat_id` in (' . implode(',', $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' - ) { - if($row['compressor_mode'] === 'heat') { - $row['heat'] = max( - $row['compressor_1'], - $row['compressor_2'] - ); - } else { - $row['heat'] = 0; - } - $row['auxiliary_heat'] = max( - $row['auxiliary_heat_1'], - $row['auxiliary_heat_2'] - ); - } else { - $row['heat'] = max( - $row['auxiliary_heat_1'], - $row['auxiliary_heat_2'] - ); - $row['auxiliary_heat'] = 0; - } - - if($row['compressor_mode'] === 'cool') { - $row['cool'] = max( - $row['compressor_1'], - $row['compressor_2'] - ); - } else { - $row['cool'] = 0; - } - - $timestamp = strtotime($row['timestamp']); - if (isset($runtime[$timestamp]) === false) { - $runtime[$timestamp] = []; - } - $runtime[$timestamp][$row['thermostat_id']] = $row; - } - } - - if( - isset($runtime[$current_timestamp]) === true && // Had data for at least one thermostat - isset($runtime[$current_timestamp][$thermostat_id]) === true // Had data for the requested thermostat - ) { - $current_runtime = $runtime[$current_timestamp][$thermostat_id]; - if($current_runtime['outdoor_temperature'] !== null) { - // Rounds to the nearest degree (because temperatures are stored in tenths). - $current_runtime['outdoor_temperature'] = round($current_runtime['outdoor_temperature'] / 10) * 10; - - // Applies further smoothing if required. - $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($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_thermostat_id => $thermostat_runtime) { - if( - $thermostat_runtime['compressor_1'] !== 0 || - $thermostat_runtime['compressor_2'] !== 0 || - $thermostat_runtime['auxiliary_heat_1'] !== 0 || - $thermostat_runtime['auxiliary_heat_2'] !== 0 || - $thermostat_runtime['outdoor_temperature'] === null || - $thermostat_runtime['indoor_temperature'] === null || - ( - // Wasn't syncing this until mid-November 2018. Just going with December to be safe. - $thermostat_runtime['climate_runtime_thermostat_text_id'] === 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_thermostat_id !== $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['event_runtime_thermostat_text_id'] !== $begin_runtime['heat']['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['heat']['climate_runtime_thermostat_text_id'] || - $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['event_runtime_thermostat_text_id'] !== $begin_runtime['cool']['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['cool']['climate_runtime_thermostat_text_id'] || - $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['event_runtime_thermostat_text_id'] !== $begin_runtime['resist']['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['resist']['climate_runtime_thermostat_text_id'] || - $all_off === false - ) - ) - ) { - // By default the end sample is the previous sample (five minutes ago). - $offset = $five_minutes; - - // If event_runtime_thermostat_text_id or climate_runtime_thermostat_text_id changes, need to ignore data - // from the previous 30 minutes as there are sensors changing during - // that time. - if( - $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime[$sample_type]['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime[$sample_type]['climate_runtime_thermostat_text_id'] - ) { - $offset = $thirty_minutes; - } else { - // Start looking ahead into the next 30 minutes looking for changes - // to event_runtime_thermostat_text_id and climate_runtime_thermostat_text_id. - $lookahead = $five_minutes; - while($lookahead <= $thirty_minutes) { - if( - isset($runtime[$current_timestamp + $lookahead]) === true && - isset($runtime[$current_timestamp + $lookahead][$thermostat_id]) === true && - ( - $runtime[$current_timestamp + $lookahead][$thermostat_id]['event_runtime_thermostat_text_id'] !== $current_runtime['event_runtime_thermostat_text_id'] || - $runtime[$current_timestamp + $lookahead][$thermostat_id]['climate_runtime_thermostat_text_id'] !== $current_runtime['climate_runtime_thermostat_text_id'] - ) - ) { - $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][$thermostat_id]) === true && - ($current_timestamp - $offset) > strtotime($begin_runtime[$sample_type]['timestamp']) - ) { - $end_runtime = $runtime[$current_timestamp - $offset][$thermostat_id]; - } else { - $end_runtime = null; - } - - if($end_runtime !== null) { - $delta = $end_runtime['indoor_temperature'] - $begin_runtime[$sample_type]['indoor_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['indoor_temperature'] === null || - $current_runtime['auxiliary_heat'] > 0 - ) { - unset($begin_runtime['heat']); - } - if( - $cool_on_for === 0 || - $current_runtime['outdoor_temperature'] === null || - $current_runtime['indoor_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 index b0e2965..08f0387 100644 --- a/api/thermostat.php +++ b/api/thermostat.php @@ -12,15 +12,135 @@ class thermostat extends cora\crud { 'read_id', 'sync', 'dismiss_alert', - 'restore_alert' + 'restore_alert', + 'set_reported_system_types', + 'generate_profile', + 'get_metrics' ], 'public' => [] ]; public static $cache = [ - 'sync' => 180 // 3 Minutes + 'sync' => 180, // 3 Minutes + 'generate_profile' => 604800, // 7 Days + 'get_metrics' => 604800 // 7 Days ]; + /** + * Updates a thermostat normally, plus does the generated columns. + * + * @param array $attributes + * + * @return array + */ + public function update($attributes) { + return parent::update(array_merge($attributes, $this->get_generated_columns($attributes))); + } + + /** + * Get all of the generated columns. + * + * @param array $attributes The thermostat. + * + * @return array The generated columns only. + */ + private function get_generated_columns($attributes) { + $generated_columns = []; + + if(isset($attributes['system_type2']) === true) { + foreach(['heat', 'heat_auxiliary', 'cool'] as $mode) { + if($attributes['system_type2']['reported'][$mode]['equipment'] !== null) { + $generated_columns['system_type_' . $mode] = $attributes['system_type2']['reported'][$mode]['equipment']; + } else { + $generated_columns['system_type_' . $mode] = $attributes['system_type2']['detected'][$mode]['equipment']; + } + if($attributes['system_type2']['reported'][$mode]['stages'] !== null) { + $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type2']['reported'][$mode]['stages']; + } else { + $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type2']['detected'][$mode]['stages']; + } + } + } + + if(isset($attributes['property']) === true) { + foreach(['age', 'square_feet', 'stories', 'structure_type'] as $characteristic) { + $generated_columns['property_' . $characteristic] = $attributes['property'][$characteristic]; + } + } + + if(isset($attributes['address_id']) === true) { + $address = $this->api('address', 'get', $attributes['address_id']); + if( + isset($address['normalized']['metadata']) === true && + $address['normalized']['metadata'] !== null && + isset($address['normalized']['metadata']['latitude']) === true && + $address['normalized']['metadata']['latitude'] !== null && + isset($address['normalized']['metadata']['longitude']) === true && + $address['normalized']['metadata']['longitude'] !== null + ) { + $generated_columns['address_latitude'] = $address['normalized']['metadata']['latitude']; + $generated_columns['address_longitude'] = $address['normalized']['metadata']['longitude']; + } else { + $generated_columns['address_latitude'] = null; + $generated_columns['address_longitude'] = null; + } + } + + return $generated_columns; + } + + /** + * Update the reported system type of this thermostat. + * + * @param int $thermostat_id + * @param array $system_types + * + * @return array The updated thermostat. + */ + public function set_reported_system_types($thermostat_id, $system_types) { + // Redundant, but makes sure you have access to edit the thermostat you + // submitted. + $thermostat = $this->get($thermostat_id); + + foreach($system_types as $system_type => $value) { + if(in_array($system_type, ['heat', 'heat_auxiliary', 'cool']) === true) { + $thermostat['system_type2']['reported'][$system_type]['equipment'] = $value; + } + } + + return $this->update($thermostat); + } + + /** + * Normal read, but filter out generated columns. These columns exist only + * for indexing and searching purposes. + * + * @param array $attributes + * @param array $columns + * + * @return array + */ + public function read($attributes = [], $columns = []) { + $thermostats = parent::read($attributes, $columns); + + foreach($thermostats as &$thermostat) { + unset($thermostat['system_type_heat']); + unset($thermostat['system_type_heat_stages']); + unset($thermostat['system_type_heat_auxiliary']); + unset($thermostat['system_type_heat_auxiliary_stages']); + unset($thermostat['system_type_cool']); + unset($thermostat['system_type_cool_stages']); + unset($thermostat['property_age']); + unset($thermostat['property_square_feet']); + unset($thermostat['property_stories']); + unset($thermostat['property_structure_type']); + unset($thermostat['address_latitude']); + unset($thermostat['address_longitude']); + } + + return $thermostats; + } + /** * Sync all thermostats for the current user. If we fail to get a lock, fail * silently (catch the exception) and just return false. @@ -96,4 +216,368 @@ class thermostat extends cora\crud { ] ); } + + /** + * Generate a new profile for this thermostat. + * + * @param int $thermostat_id + */ + public function generate_profile($thermostat_id) { + return $this->update([ + 'thermostat_id' => $thermostat_id, + 'profile' => $this->api('profile', 'generate', $thermostat_id) + ]); + } + + /** + * Compare this thermostat to all other matching ones. + * + * @param array $thermosat_id The base thermostat_id. + * @param array $attributes Optional attributes: + * property_structure_type + * property_age + * property_square_feet + * property_stories + * + * @return array + */ + public function get_metrics($thermostat_id, $attributes) { + $thermostat = $this->get($thermostat_id); + $generated_columns = $this->get_generated_columns($thermostat); + + if( + $generated_columns['system_type_heat'] === null || + $generated_columns['system_type_heat_stages'] === null || + $generated_columns['system_type_cool'] === null || + $generated_columns['system_type_cool_stages'] === null + ) { + throw new cora\exception('System type is not defined.', 10700); + } + + $where = []; + + $keys_generated_columns = [ + 'system_type_heat', + 'system_type_heat_stages', + 'system_type_cool', + 'system_type_cool_stages' + ]; + foreach($keys_generated_columns as $key) { + $where[] = $this->database->column_equals_value_where( + $key, + $generated_columns[$key] + ); + } + + $keys_custom = [ + 'property_structure_type', + 'property_age', + 'property_square_feet', + 'property_stories' + ]; + foreach($keys_custom as $key) { + if(isset($attributes[$key]) === true) { + $where[] = $this->database->column_equals_value_where( + $key, + $attributes[$key] + ); + } + } + + /** + * Normally radius implies a circle. In this case it's a square as this + * helps with some optimization. + */ + if(isset($attributes['radius']) === true) { + if( + is_array($attributes['radius']) === false || + $attributes['radius']['operator'] !== '<' + ) { + throw new \Exception('Radius must be defined as less than a value.', 10702); + } + + $radius = (int) $attributes['radius']['value']; + if( + isset($generated_columns['address_latitude']) === false || + isset($generated_columns['address_longitude']) === false + ) { + // Require a valid address (latitude/longitude) when using radius. + throw new cora\exception('Cannot compare by radius if address is invalid.', 10701); + } else { + // Latitude is 69mi / ° + $degrees_latitude_delta = $radius / 69 / 2; + $minimum_latitude = $generated_columns['address_latitude'] - $degrees_latitude_delta; + $maximum_latitude = $generated_columns['address_latitude'] + $degrees_latitude_delta; + if ($minimum_latitude < -90) { + $overflow = abs($minimum_latitude + 90); + $between_a = [-90, $maximum_latitude]; + sort($between_a); + $between_b = [90, (90 - $overflow)]; + sort($between_b); + $where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')'; + } + else if ($maximum_latitude > 90) { + $overflow = abs($maximum_latitude - 90); + $between_a = [90, $minimum_latitude]; + sort($between_a); + $between_b = [-90, (-90 + $overflow)]; + sort($between_b); + $where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')'; + } + else { + $between_a = [$minimum_latitude, $maximum_latitude]; + sort($between_a); + $where[] = '`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1]; + } + + // Longitude is 69mi / ° at the equator and then shrinks towards the poles. + $degrees_longitude_delta = $radius / 69 / 2; + $minimum_longitude = $generated_columns['address_longitude'] - $degrees_longitude_delta; + $maximum_longitude = $generated_columns['address_longitude'] + $degrees_longitude_delta; + if ($minimum_longitude < -180) { + $overflow = abs($minimum_longitude + 180); + $between_a = [-180, $maximum_longitude]; + sort($between_a); + $between_b = [180, (180 - $overflow)]; + sort($between_b); + $where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')'; + } + else if ($maximum_longitude > 180) { + $overflow = abs($maximum_longitude - 180); + $between_a = [180, $minimum_longitude]; + sort($between_a); + $between_b = [-180, (-180 + $overflow)]; + sort($between_b); + $where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')'; + } + else { + $between_a = [$minimum_longitude, $maximum_longitude]; + sort($between_a); + $where[] = '`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1]; + } + } + } + + // Should match their position in the thermostat profile exactly. + $metric_codes = [ + 'property' => [ + 'age', + 'square_feet' + ], + 'runtime_per_degree_day' => [ + 'heat_1', + 'heat_2', + 'cool_1', + 'cool_2' + ], + 'setpoint' => [ + 'heat', + 'cool' + ], + 'setback' => [ + 'heat', + 'cool' + ], + 'balance_point' => [ + 'heat_1', + 'heat_2', + 'resist' + ] + ]; + + // Set all of the metric intervals. If Celsius add a bit of precision. + $intervals = []; + + $intervals['property'] = [ + 'age' => 1, + 'square_feet' => 500 + ]; + + $intervals['runtime_per_degree_day'] = [ + 'heat_1' => 1, + 'heat_2' => 1, + 'cool_1' => 1, + 'cool_2' => 1 + ]; + + $intervals['setpoint'] = [ + 'heat' => 0.5, + 'cool' => 0.5 + ]; + + if($thermostat['temperature_unit'] === '°F') { + $intervals['setback'] = [ + 'heat' => 1, + 'cool' => 1 + ]; + + $intervals['balance_point'] = [ + 'heat_1' => 1, + 'heat_2' => 1, + 'resist' => 1 + ]; + } else { + $intervals['setback'] = [ + 'heat' => 0.5, + 'cool' => 0.5 + ]; + + $intervals['balance_point'] = [ + 'heat_1' => 0.5, + 'heat_2' => 0.5, + 'resist' => 0.5 + ]; + } + + $get_metric_template = function() { + return [ + 'values' => [], + 'histogram' => [], + 'standard_deviation' => null, + 'median' => null, + 'precision' => null + ]; + }; + + $metrics = []; + foreach($metric_codes as $parent_metric_name => $parent_metric) { + $metrics[$parent_metric_name] = []; + foreach($parent_metric as $child_metric_name) { + $metrics[$parent_metric_name][$child_metric_name] = $get_metric_template(); + $metrics[$parent_metric_name][$child_metric_name]['interval'] = $intervals[$parent_metric_name][$child_metric_name]; + } + } + + $limit_start = 0; + $limit_count = 250; + + /** + * Selecting lots of rows can eventually run PHP out of memory, so chunk + * this up into several queries to avoid that. + */ + do { + $result = $this->database->query(' + select + * + from + thermostat + where ' . + implode(' and ', $where) . ' + limit ' . $limit_start . ',' . $limit_count . ' + '); + + // Get all the scores from the other thermostats + while($other_thermostat = $result->fetch_assoc()) { + $other_thermostat['profile'] = json_decode($other_thermostat['profile'], true); + // Only use profiles with at least a year of data + // Only use profiles generated in the past year + if( + $other_thermostat['profile']['metadata']['duration'] >= 365 && + strtotime($other_thermostat['profile']['metadata']['generated_at']) > strtotime('-1 year') && + $other_thermostat['thermostat_id'] !== $thermostat_id + ) { + foreach($metric_codes as $parent_metric_name => $parent_metric) { + foreach($parent_metric as $child_metric_name) { + if( + isset($thermostat['profile'][$parent_metric_name]) === true && + isset($thermostat['profile'][$parent_metric_name][$child_metric_name]) === true && + $thermostat['profile'][$parent_metric_name][$child_metric_name] !== null && + isset($other_thermostat['profile'][$parent_metric_name]) === true && + isset($other_thermostat['profile'][$parent_metric_name][$child_metric_name]) === true && + $other_thermostat['profile'][$parent_metric_name][$child_metric_name] !== null + ) { + $interval = $intervals[$parent_metric_name][$child_metric_name]; + $data = round($other_thermostat['profile'][$parent_metric_name][$child_metric_name] / $interval) * $interval; + + $precision = strlen(substr(strrchr($interval, "."), 1)); + $data = number_format($data, $precision, '.', ''); + + $metrics[$parent_metric_name][$child_metric_name]['values'][] = $data; + } + } + } + } + } + + $limit_start += $limit_count; + } while ($result->num_rows === $limit_count); + + // Cleanup. Set the standard deviation, median, and remove the temporary + // values and any metrics that have no data. + foreach($metric_codes as $parent_metric_name => $parent_metric) { + foreach($parent_metric as $child_metric_name) { + $data = $this->remove_outliers($metrics[$parent_metric_name][$child_metric_name]['values']); + // print_r($data); + if(count($data['values']) > 0) { + $metrics[$parent_metric_name][$child_metric_name]['histogram'] = $data['histogram']; + $metrics[$parent_metric_name][$child_metric_name]['standard_deviation'] = $this->standard_deviation($data['values']); + $metrics[$parent_metric_name][$child_metric_name]['median'] = floatval(array_median($data['values'])); + unset($metrics[$parent_metric_name][$child_metric_name]['values']); + } else { + $metrics[$parent_metric_name][$child_metric_name] = null; + } + } + } + + return $metrics; + } + + /** + * Calculate the standard deviation of an array of numbers. + * + * @param array $array The values. + * + * @return int The standard deviation. + */ + private function standard_deviation($array) { + $count = count($array); + + if ($count === 0) { + return null; + } + + $mean = array_mean($array); + + $variance = 0; + foreach($array as $i) { + $variance += pow(($i - $mean), 2); + } + + return round(sqrt($variance / $count), 1); + } + + /** + * Remove outliers more than 2 standard deviations away from the mean. This + * is an effective way to keep the scales meaningul for normal data. + * + * @param array $array Input array + * + * @return array Input array minus outliers. + */ + private function remove_outliers($array) { + $mean = array_mean($array); + $standard_deviation = $this->standard_deviation($array); + + $min = $mean - ($standard_deviation * 2); + $max = $mean + ($standard_deviation * 2); + + $values = []; + $histogram = []; + foreach($array as $value) { + if($value >= $min && $value <= $max) { + $values[] = $value; + + $value_string = strval($value); + if(isset($histogram[$value_string]) === false) { + $histogram[$value_string] = 0; + } + $histogram[$value_string]++; + } + } + + return [ + 'values' => $values, + 'histogram' => $histogram + ]; + } } diff --git a/api/thermostat_group.php b/api/thermostat_group.php deleted file mode 100644 index df0085d..0000000 --- a/api/thermostat_group.php +++ /dev/null @@ -1,904 +0,0 @@ - [ - 'read_id', - 'generate_temperature_profiles', - 'generate_temperature_profile', - 'generate_profiles', - 'generate_profile', - 'get_scores', - 'get_metrics', - 'update_system_types' - ], - 'public' => [] - ]; - - public static $cache = [ - 'generate_temperature_profile' => 604800, // 7 Days - 'generate_temperature_profiles' => 604800, // 7 Days - 'generate_profile' => 604800, // 7 Days - 'generate_profiles' => 604800, // 7 Days - 'get_scores' => 604800, // 7 Days - // 'get_metrics' => 604800 // 7 Days - ]; - - /** - * Generate the group temperature profile. - * - * @param int $thermostat_group_id - * - * @return array - */ - public function generate_profile($thermostat_group_id) { - // Get all thermostats in this group. - $thermostats = $this->api( - 'thermostat', - 'read', - [ - 'attributes' => [ - 'thermostat_group_id' => $thermostat_group_id, - 'inactive' => 0 - ] - ] - ); - - // Generate a temperature profile for each thermostat in this group. - $profiles = []; - foreach($thermostats as $thermostat) { - $profile = $this->api('profile', 'generate', $thermostat['thermostat_id']); - - $this->api( - 'thermostat', - 'update', - [ - 'attributes' => [ - 'thermostat_id' => $thermostat['thermostat_id'], - 'profile' => $profile - ] - ] - ); - - $profiles[] = $profile; - } - - // Get all of the individual deltas for averaging. - $group_profile = [ - 'setpoint' => [ - 'heat' => null, - 'cool' => null - ], - 'degree_days' => [ - 'heat' => null, - 'cool' => null - ], - 'runtime' => [ - 'heat_1' => 0, - 'heat_2' => 0, - 'auxiliary_heat_1' => 0, - 'auxiliary_heat_2' => 0, - 'cool_1' => 0, - 'cool_2' => 0 - ], - 'metadata' => [ - 'generated_at' => date('c'), - 'duration' => null, - 'setpoint' => [ - 'heat' => [ - 'samples' => null - ], - 'cool' => [ - 'samples' => null - ] - ], - 'temperature' => [] - ] - ]; - - if (count($profiles) === 0) { - $this->update( - [ - 'thermostat_group_id' => $thermostat_group_id, - 'profile' => $group_profile - ] - ); - - return $group_profile; - } - - $metadata_duration = []; - - // Setpoint heat min/max/average. - $metadata_setpoint_heat_samples = []; - $setpoint_heat = []; - - // Setpoint cool min/max/average. - $metadata_setpoint_cool_samples = []; - $setpoint_cool = []; - - // Temperature profiles. - $temperature = []; - $metadata_temperature = []; - - foreach($profiles as $profile) { - // Group profile duration is the minimum of all individual profile - // durations. - if($profile['metadata']['duration'] !== null) { - $metadata_duration[] = $profile['metadata']['duration']; - } - - if($profile['setpoint']['heat'] !== null) { - $setpoint_heat[] = [ - 'value' => $profile['setpoint']['heat'], - 'samples' => $profile['metadata']['setpoint']['heat']['samples'] - ]; - $metadata_setpoint_heat_samples[] = $profile['metadata']['setpoint']['heat']['samples']; - } - - if($profile['setpoint']['cool'] !== null) { - $setpoint_cool[] = [ - 'value' => $profile['setpoint']['cool'], - 'samples' => $profile['metadata']['setpoint']['cool']['samples'] - ]; - $metadata_setpoint_cool_samples[] = $profile['metadata']['setpoint']['cool']['samples']; - } - - // Temperature profiles. - foreach($profile['temperature'] as $type => $data) { - if($data !== null) { - foreach($data['deltas'] as $outdoor_temperature => $delta) { - $temperature[$type]['deltas'][$outdoor_temperature][] = [ - 'value' => $delta, - 'samples' => $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] - ]; - $metadata_temperature[$type]['deltas'][$outdoor_temperature]['samples'][] = - $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples']; - } - } - } - - // Degree days. - if($profile['degree_days']['heat'] !== null) { - $group_profile['degree_days']['heat'] += $profile['degree_days']['heat']; - } - if($profile['degree_days']['cool'] !== null) { - $group_profile['degree_days']['cool'] += $profile['degree_days']['cool']; - } - - // Runtime - $group_profile['runtime']['heat_1'] += $profile['runtime']['heat_1']; - $group_profile['runtime']['heat_2'] += $profile['runtime']['heat_2']; - $group_profile['runtime']['auxiliary_heat_1'] += $profile['runtime']['auxiliary_heat_1']; - $group_profile['runtime']['auxiliary_heat_2'] += $profile['runtime']['auxiliary_heat_2']; - $group_profile['runtime']['cool_1'] += $profile['runtime']['cool_1']; - $group_profile['runtime']['cool_2'] += $profile['runtime']['cool_2']; - } - - // echo '
';
-    // print_r($profiles);
-    // die();
-
-    $group_profile['metadata']['duration'] = min($metadata_duration);
-
-    // Setpoint heat min/max/average.
-    $group_profile['metadata']['setpoint']['heat']['samples'] = array_sum($metadata_setpoint_heat_samples);
-    if($group_profile['metadata']['setpoint']['heat']['samples'] > 0) {
-      $group_profile['setpoint']['heat'] = 0;
-      foreach($setpoint_heat as $data) {
-        $group_profile['setpoint']['heat'] +=
-          ($data['value'] * $data['samples'] / $group_profile['metadata']['setpoint']['heat']['samples']);
-      }
-    }
-
-    // Setpoint cool min/max/average.
-    $group_profile['metadata']['setpoint']['cool']['samples'] = array_sum($metadata_setpoint_cool_samples);
-    if($group_profile['metadata']['setpoint']['cool']['samples'] > 0) {
-      $group_profile['setpoint']['cool'] = 0;
-      foreach($setpoint_cool as $data) {
-        $group_profile['setpoint']['cool'] +=
-          ($data['value'] * $data['samples'] / $group_profile['metadata']['setpoint']['cool']['samples']);
-      }
-    }
-
-    // echo '
';
-    // print_r($temperature);
-    // die();
-
-    // Temperature profiles.
-    foreach($temperature as $type => $data) {
-      foreach($data['deltas'] as $outdoor_temperature => $delta) {
-        $group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] =
-          array_sum($metadata_temperature[$type]['deltas'][$outdoor_temperature]['samples']);
-        if($group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] > 0) {
-          $group_profile['temperature'][$type]['deltas'][$outdoor_temperature] = 0;
-          foreach($temperature[$type]['deltas'][$outdoor_temperature] as $data) {
-            $group_profile['temperature'][$type]['deltas'][$outdoor_temperature] +=
-              ($data['value'] * $data['samples'] / $group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples']);
-          }
-        }
-      }
-      ksort($group_profile['temperature'][$type]['deltas']);
-
-      $group_profile['temperature'][$type]['linear_trendline'] = $this->api(
-        'profile',
-        'get_linear_trendline',
-        ['data' => $group_profile['temperature'][$type]['deltas']]
-      );
-
-    }
-
-    // echo '
';
-    // print_r($group_profile);
-    // die();
-
-    $this->update(
-      [
-        'thermostat_group_id' => $thermostat_group_id,
-        'profile' => $group_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_profile['temperature'][$type]) === false) {
-        $group_profile['temperature'][$type] = null;
-      }
-    }
-
-    return $group_profile;
-  }
-
-  /**
-   * Generate temperature profiles for all thermostat_groups. This pretty much
-   * only exists for the cron job.
-   */
-  public function generate_profiles() {
-    // Get all thermostat_groups.
-    $thermostat_groups = $this->read();
-    foreach($thermostat_groups as $thermostat_group) {
-      $this->generate_profile(
-        $thermostat_group['thermostat_group_id'],
-        null,
-        null
-      );
-    }
-
-    $this->api(
-      'user',
-      'update_sync_status',
-      ['key' => 'thermostat_group.generate_profiles']
-    );
-  }
-
-  /**
-   * 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,
-          'inactive' => 0
-        ]
-      ]
-    );
-
-    // 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) {
-    // See #281
-    set_time_limit(30);
-
-    // 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']);
-    }
-
-    $scores = [];
-    $limit_start = 0;
-    $limit_count = 100;
-
-    /**
-     * Selecting lots of rows can eventually run PHP out of memory, so chunk
-     * this up into several queries to avoid that.
-     */
-    do {
-      // Get all matching thermostat groups.
-      $other_thermostat_groups = $this->database->read(
-        'thermostat_group',
-        $attributes,
-        [], // columns
-        [], // order_by
-        [$limit_start, $limit_count] // limit
-      );
-
-      // Get all the scores from the other thermostat groups
-      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'];
-        }
-      }
-
-      $limit_start += $limit_count;
-    } while (count($other_thermostat_groups) === $limit_count);
-
-    sort($scores);
-
-    return $scores;
-  }
-
-  /**
-   * Compare this thermostat_group to all other matching ones.
-   *
-   * @param array $attributes The attributes to compare to.
-   *
-   * @return array
-   */
-  public function get_metrics($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']);
-    }
-
-    $metric_codes = [
-      'setpoint_heat',
-      'setpoint_cool',
-      // 'runtime_per_heating_degree_day',
-      'runtime_percentage_heat_1',
-      'runtime_percentage_heat_2',
-      'runtime_percentage_auxiliary_heat_1',
-      'runtime_percentage_auxiliary_heat_2',
-      'runtime_percentage_cool_1',
-      'runtime_percentage_cool_2'
-    ];
-
-    $metrics = [];
-    foreach($metric_codes as $metric_code) {
-      $metrics[$metric_code] = [
-        'values' => [],
-        'histogram' => [],
-        'standard_deviation' => null,
-        'median' => null
-      ];
-    }
-
-    $limit_start = 0;
-    $limit_count = 100;
-
-    /**
-     * Selecting lots of rows can eventually run PHP out of memory, so chunk
-     * this up into several queries to avoid that.
-     */
-    do {
-      // Get all matching thermostat groups.
-      $other_thermostat_groups = $this->database->read(
-        'thermostat_group',
-        $attributes,
-        [], // columns
-        [], // order_by
-        [$limit_start, $limit_count] // limit
-      );
-
-      // Get all the scores from the other thermostat groups
-      foreach($other_thermostat_groups as $other_thermostat_group) {
-        // Only use profiles with at least a year of data
-        // Only use profiles generated in the past year
-        //
-        if(
-          $other_thermostat_group['profile']['metadata']['duration'] >= 365 &&
-          strtotime($other_thermostat_group['profile']['metadata']['generated_at']) > strtotime('-1 year')
-        ) {
-          // 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;
-          }
-
-          // setpoint_heat
-          if($other_thermostat_group['profile']['setpoint']['heat'] !== null) {
-            $setpoint_heat = round($other_thermostat_group['profile']['setpoint']['heat']);
-            if(isset($metrics['setpoint_heat']['histogram'][$setpoint_heat]) === false) {
-              $metrics['setpoint_heat']['histogram'][$setpoint_heat] = 0;
-            }
-            $metrics['setpoint_heat']['histogram'][$setpoint_heat]++;
-            $metrics['setpoint_heat']['values'][] = $setpoint_heat;
-          }
-
-          // setpoint_cool
-          if($other_thermostat_group['profile']['setpoint']['cool'] !== null) {
-            $setpoint_cool = round($other_thermostat_group['profile']['setpoint']['cool']);
-            if(isset($metrics['setpoint_cool']['histogram'][$setpoint_cool]) === false) {
-              $metrics['setpoint_cool']['histogram'][$setpoint_cool] = 0;
-            }
-            $metrics['setpoint_cool']['histogram'][$setpoint_cool]++;
-            $metrics['setpoint_cool']['values'][] = $setpoint_cool;
-          }
-
-
-          // runtime_per_heating_degree_day
-          // todo division by 0 error here. Also remove this?
-/*          if(
-            isset($other_thermostat_group['profile']) === true &&
-            isset($other_thermostat_group['profile']['runtime']) == true &&
-            $other_thermostat_group['profile']['runtime']['heat_1'] !== null &&
-            isset($other_thermostat_group['profile']['degree_days']) === true &&
-            $other_thermostat_group['profile']['degree_days']['heat'] !== null
-          ) {
-            $runtime_per_heating_degree_day = round(
-              $other_thermostat_group['profile']['runtime']['heat_1'] / $other_thermostat_group['profile']['degree_days']['heat'],
-              1
-            );
-            if(isset($metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day]) === false) {
-              $metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day] = 0;
-            }
-            $metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day]++;
-            $metrics['runtime_per_heating_degree_day']['values'][] = $runtime_per_heating_degree_day;
-          }*/
-
-
-
-
-          // runtime_percentage_heat_1
-/*          $total_runtime_heat =
-            $other_thermostat_group['profile']['runtime']['heat_1'] +
-            $other_thermostat_group['profile']['runtime']['heat_2'] +
-            $other_thermostat_group['profile']['runtime']['auxiliary_heat_1'] +
-            $other_thermostat_group['profile']['runtime']['auxiliary_heat_2'];
-
-          if($total_runtime_heat > 0) {
-            $runtime_percentage_heat_1 = $other_thermostat_group['profile']['runtime']['heat_1'] / $total_runtime_heat * 100;
-
-            if(isset($metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1]) === false) {
-              $metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1] = 0;
-            }
-            $metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1]++;
-            $metrics['runtime_percentage_heat_1']['values'][] = $runtime_percentage_heat_1;
-          }*/
-        }
-      }
-
-      $limit_start += $limit_count;
-    } while (count($other_thermostat_groups) === $limit_count);
-
-    // setpoint_heat
-    $metrics['setpoint_heat']['standard_deviation'] = round($this->standard_deviation(
-      $metrics['setpoint_heat']['values']
-    ), 2);
-    $metrics['setpoint_heat']['median'] = array_median($metrics['setpoint_heat']['values']);
-    unset($metrics['setpoint_heat']['values']);
-
-    // setpoint_cool
-    $metrics['setpoint_cool']['standard_deviation'] = round($this->standard_deviation(
-      $metrics['setpoint_cool']['values']
-    ), 2);
-    $metrics['setpoint_cool']['median'] = array_median($metrics['setpoint_cool']['values']);
-    unset($metrics['setpoint_cool']['values']);
-
-    // runtime_per_heating_degree_day
-    // $metrics['runtime_per_heating_degree_day']['standard_deviation'] = round($this->standard_deviation(
-    //   $metrics['runtime_per_heating_degree_day']['values']
-    // ), 2);
-    // $metrics['runtime_per_heating_degree_day']['median'] = array_median($metrics['runtime_per_heating_degree_day']['values']);
-    // unset($metrics['runtime_per_heating_degree_day']['values']);
-
-    // runtime_percentage_heat_1
-    // $metrics['runtime_percentage_heat_1']['standard_deviation'] = round($this->standard_deviation(
-    //   $metrics['runtime_percentage_heat_1']['values']
-    // ), 2);
-    // $metrics['runtime_percentage_heat_1']['median'] = array_median($metrics['runtime_percentage_heat_1']['values']);
-    // unset($metrics['runtime_percentage_heat_1']['values']);
-
-    return $metrics;
-  }
-
-  private function standard_deviation($array) {
-    $count = count($array);
-
-    $mean = array_mean($array);
-
-    $variance = 0;
-    foreach($array as $i) {
-      $variance += pow(($i - $mean), 2);
-    }
-
-    return sqrt($variance / $count);
-  }
-
-  /**
-   * 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',
-      'weather'
-    ];
-
-    $thermostats = $this->api(
-      'thermostat',
-      'read',
-      [
-        'attributes' => [
-          'thermostat_group_id' => $thermostat_group_id,
-          'inactive' => 0
-        ]
-      ]
-    );
-
-    $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;
-          default:
-            // Stuff that doesn't really matter (weather); just pick the last
-            // one.
-            $final_attributes[$attribute] = $thermostat[$attribute];
-          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/img/nest/connect.png b/img/nest/connect.png
deleted file mode 100644
index 0d7310a2275cbb6a62b5f0e5b6919d7124211080..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 7613
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 diff --git a/img/nest/logo.svg b/img/nest/logo.svg deleted file mode 100644 index f1fcbaf..0000000 --- a/img/nest/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/img/waveform.png b/img/waveform.png deleted file mode 100644 index c0ae8e30d2eeb8cfefb0e7465c1884322d43562c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9233 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 diff --git a/js/beestat.js b/js/beestat.js index 9416b37..f1cf3f3 100644 --- a/js/beestat.js +++ b/js/beestat.js @@ -56,7 +56,7 @@ beestat.get_climate = function(climate_ref) { /** * Get the color a thermostat should be based on what equipment is running. * - * @return {string} + * @return {string} The color string. */ beestat.get_thermostat_color = function(thermostat_id) { var thermostat = beestat.cache.thermostat[thermostat_id]; @@ -131,7 +131,8 @@ beestat.width = window.innerWidth; window.addEventListener('resize', rocket.throttle(100, function() { var breakpoints = [ 600, - 650 + 650, + 1000 ]; breakpoints.forEach(function(breakpoint) { diff --git a/js/beestat/comparisons.js b/js/beestat/comparisons.js index 4c7573b..c9b1b83 100644 --- a/js/beestat/comparisons.js +++ b/js/beestat/comparisons.js @@ -1,98 +1,32 @@ beestat.comparisons = {}; -/** - * 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.comparisons.get_comparison_scores = function(callback) { - var types = [ - 'heat', - 'cool', - 'resist' - ]; - - var api = new beestat.api(); - types.forEach(function(type) { - beestat.cache.delete('data.comparison_scores_' + type); - api.add_call( - 'thermostat_group', - 'get_scores', - { - 'type': type, - 'attributes': beestat.comparisons.get_comparison_attributes(type) - }, - 'score_' + type - ); - }); - - if (beestat.user.has_early_access() === true) { - api.add_call( - 'thermostat_group', - 'get_metrics', - { - 'attributes': beestat.comparisons.get_comparison_attributes('resist') // todo - }, - 'metrics' - ); - } - - api.set_callback(function(data) { - if (beestat.user.has_early_access() === true) { - beestat.cache.set('data.metrics', data.metrics); - } - - types.forEach(function(type) { - beestat.cache.set('data.comparison_scores_' + type, data['score_' + 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. + * @return {object} The comparison attributes. */ -beestat.comparisons.get_comparison_attributes = function(type) { +beestat.comparisons.get_attributes = 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.address_id]; var attributes = {}; if (beestat.setting('comparison_property_type') === 'similar') { // Match structure type exactly. - if (thermostat_group.property_structure_type !== null) { + if (thermostat.property.structure_type !== null) { attributes.property_structure_type = - thermostat_group.property_structure_type; + thermostat.property.structure_type; } // Always a 10 year age delta on both sides. - if (thermostat_group.property_age !== null) { + if (thermostat.property.age !== null) { var property_age_delta = 10; var min_property_age = Math.max( 0, - thermostat_group.property_age - property_age_delta + thermostat.property.age - property_age_delta ); - var max_property_age = thermostat_group.property_age + property_age_delta; + var max_property_age = thermostat.property.age + property_age_delta; attributes.property_age = { 'operator': 'between', 'value': [ @@ -103,14 +37,14 @@ beestat.comparisons.get_comparison_attributes = function(type) { } // Always a 1000sqft size delta on both sides (total 2000 sqft). - if (thermostat_group.property_square_feet !== null) { + if (thermostat.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 + thermostat.property.square_feet - property_square_feet_delta ); var max_property_square_feet = - thermostat_group.property_square_feet + + thermostat.property.square_feet + property_square_feet_delta; attributes.property_square_feet = { 'operator': 'between', @@ -126,40 +60,39 @@ beestat.comparisons.get_comparison_attributes = function(type) { * Apartments ignore this. */ if ( - thermostat_group.property_stories !== null && - thermostat_group.property_structure_type !== 'apartment' + thermostat.property.stories !== null && + thermostat.property.structure_type !== 'apartment' ) { - if (thermostat_group.property_stories < 2) { - attributes.property_stories = thermostat_group.property_stories; + if (thermostat.property.stories < 2) { + attributes.property_stories = thermostat.property.stories; } else { attributes.property_stories = { 'operator': '>=', - 'value': thermostat_group.property_stories + 'value': thermostat.property.stories }; } } } else if (beestat.setting('comparison_property_type') === 'same_structure') { // Match structure type exactly. - if (thermostat_group.property_structure_type !== null) { + if (thermostat.property.structure_type !== null) { attributes.property_structure_type = - thermostat_group.property_structure_type; + thermostat.property.structure_type; } } if ( - thermostat_group.address_latitude !== null && - thermostat_group.address_longitude !== null && + address.normalized !== null && + address.normalized.metadata !== undefined && + address.normalized.metadata.latitude !== undefined && + address.normalized.metadata.latitude !== null && + address.normalized.metadata.longitude !== undefined && + address.normalized.metadata.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; + attributes.radius = { + 'operator': '<', + 'value': 250 + }; } return attributes; diff --git a/js/beestat/poll.js b/js/beestat/poll.js index 6d8a388..b09ab1b 100644 --- a/js/beestat/poll.js +++ b/js/beestat/poll.js @@ -1,35 +1,13 @@ -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); + window.clearTimeout(beestat.poll_timeout); if (beestat.poll_intervals.length > 0) { - beestat.poll_timeout = setTimeout( + beestat.poll_timeout = window.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. */ diff --git a/js/beestat/requestor.js b/js/beestat/requestor.js index a8b1abe..64d0edf 100644 --- a/js/beestat/requestor.js +++ b/js/beestat/requestor.js @@ -110,14 +110,3 @@ beestat.requestor.callback = function(response, api) { beestat.requestor.timeout_ = window.setTimeout(beestat.requestor.send, 3000); } }; - -/* - - -beestat.requestor.request([{'resource': 'thermostat','method': 'read_id','arguments': {'attributes': {'thermostat_id': 1}}}]); -beestat.requestor.request([{'resource': 'thermostat','method': 'read_id','arguments': {'attributes': {'thermostat_id': 1}}}]); -beestat.requestor.request([{'resource': 'sensor','method': 'read_id','arguments': {'attributes': {'sensor_id': 1}}}]); -beestat.requestor.request([{'resource': 'sensor','method': 'read_id','arguments': {'attributes': {'sensor_id': 2}}}]); - - - */ diff --git a/js/beestat/runtime_thermostat.js b/js/beestat/runtime_thermostat.js index bc98ef1..8ded684 100644 --- a/js/beestat/runtime_thermostat.js +++ b/js/beestat/runtime_thermostat.js @@ -6,10 +6,14 @@ beestat.runtime_thermostat = {}; * * @param {number} thermostat_id The thermostat_id to get data for. * @param {object} range Range settings. + * @param {string} key The key to pull the data from inside + * beestat.cache.data. This exists because runtime_thermostat data exists in + * two spots: one for the Thermostat Detail chart, and once for the Sensor + * Detail chart. * * @return {object} The data. */ -beestat.runtime_thermostat.get_data = function(thermostat_id, range) { +beestat.runtime_thermostat.get_data = function(thermostat_id, range, key) { var data = { 'x': [], 'series': {}, @@ -131,7 +135,7 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) { .second(0) .millisecond(0); - var runtime_thermostats = beestat.runtime_thermostat.get_runtime_thermostats_by_date_(); + var runtime_thermostats = beestat.runtime_thermostat.get_runtime_thermostats_by_date_(key); // Initialize moving average. var moving = []; @@ -211,7 +215,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) { data.metadata.series.setpoint_heat.data[current_m.valueOf()] = setpoint_heat; data.metadata.series.setpoint_heat.active = true; - } else { data.series.setpoint_heat.push(null); } @@ -227,7 +230,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) { data.metadata.series.setpoint_cool.data[current_m.valueOf()] = setpoint_cool; data.metadata.series.setpoint_cool.active = true; - } else { data.series.setpoint_cool.push(null); } @@ -461,7 +463,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) { data.metadata.series[series_code_1].data[current_m.valueOf()] = equipment_y[series_code_1]; - } } else { data.series[series_code].push(null); @@ -538,12 +539,17 @@ beestat.runtime_thermostat.get_average_ = function(runtime_thermostats, series_c /** * Get all the runtime_thermostat rows indexed by date. * + * @param {string} key The key to pull the data from inside + * beestat.cache.data. This exists because runtime_thermostat data exists in + * two spots: one for the Thermostat Detail chart, and once for the Sensor + * Detail chart. + * * @return {array} The runtime_thermostat rows. */ -beestat.runtime_thermostat.get_runtime_thermostats_by_date_ = function() { +beestat.runtime_thermostat.get_runtime_thermostats_by_date_ = function(key) { var runtime_thermostats = {}; - if (beestat.cache.runtime_thermostat !== undefined) { - beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat) { + if (beestat.cache.data[key] !== undefined) { + beestat.cache.data[key].forEach(function(runtime_thermostat) { runtime_thermostats[moment(runtime_thermostat.timestamp).valueOf()] = runtime_thermostat; }); } diff --git a/js/beestat/temperature.js b/js/beestat/temperature.js index b74f033..588a255 100644 --- a/js/beestat/temperature.js +++ b/js/beestat/temperature.js @@ -13,7 +13,6 @@ * * @return {string} The formatted temperature. */ -// beestat.temperature = function(temperature, convert, round, include_units) { beestat.temperature = function(args) { // Allow passing a single argument of temperature for convenience. if (typeof args !== 'object' || args === null) { diff --git a/js/beestat/time.js b/js/beestat/time.js index 1515a51..c37c75d 100644 --- a/js/beestat/time.js +++ b/js/beestat/time.js @@ -10,20 +10,6 @@ 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'); diff --git a/js/component/card.js b/js/component/card.js index 7aaf714..a5c4255 100644 --- a/js/component/card.js +++ b/js/component/card.js @@ -123,7 +123,7 @@ beestat.component.card.prototype.decorate_top_right_ = function(parent) {}; /** * Get the title of the card. * - * @return {string} + * @return {string} The title. */ beestat.component.card.prototype.get_title_ = function() { return null; @@ -132,7 +132,7 @@ beestat.component.card.prototype.get_title_ = function() { /** * Get the subtitle of the card. * - * @return {string} + * @return {string} The subtitle. */ beestat.component.card.prototype.get_subtitle_ = function() { return null; diff --git a/js/component/card/alerts.js b/js/component/card/alerts.js index bedf663..13b4371 100644 --- a/js/component/card/alerts.js +++ b/js/component/card/alerts.js @@ -114,7 +114,7 @@ beestat.component.card.alerts.prototype.unpin_ = function() { /** * Get the title of the card. * - * @return {string} + * @return {string} The title. */ beestat.component.card.alerts.prototype.get_title_ = function() { return 'Alerts'; @@ -130,7 +130,10 @@ beestat.component.card.alerts.prototype.decorate_top_right_ = function(parent) { var menu = (new beestat.component.menu()).render(parent); - var menu_item_show = new beestat.component.menu_item() + var menu_item_show; + var menu_item_hide; + + menu_item_show = new beestat.component.menu_item() .set_text('Show dismissed') .set_icon('bell') .set_callback(function() { @@ -144,7 +147,7 @@ beestat.component.card.alerts.prototype.decorate_top_right_ = function(parent) { }); menu.add_menu_item(menu_item_show); - var menu_item_hide = new beestat.component.menu_item() + menu_item_hide = new beestat.component.menu_item() .set_text('Hide dismissed') .set_icon('bell_off') .set_callback(function() { diff --git a/js/component/card/compare_notification.js b/js/component/card/compare_notification.js new file mode 100644 index 0000000..2163f89 --- /dev/null +++ b/js/component/card/compare_notification.js @@ -0,0 +1,33 @@ +/** + * Notification at the top of the new compare page to help users along with + * the change. + */ +beestat.component.card.compare_notification = function() { + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.compare_notification, beestat.component.card); + +beestat.component.card.compare_notification.prototype.decorate_contents_ = function(parent) { + parent.style('background', beestat.style.color.blue.light); + + parent.appendChild($.createElement('p').innerText('The comparisons you\'ve become accustomed to have evolved into a new feature: Metrics! Please be patient over the next few weeks as they are refined.')); + + new beestat.component.button() + .set_icon('information') + .set_text('Learn more and discuss this change') + .set_background_color(beestat.style.color.blue.dark) + .set_background_hover_color(beestat.style.color.blue.base) + .addEventListener('click', function() { + window.open('https://community.beestat.io/t/metrics-are-replacing-scores/347'); + }) + .render(parent); +}; + +/** + * Get the title of the card. + * + * @return {string} The title. + */ +beestat.component.card.compare_notification.prototype.get_title_ = function() { + return 'Things have changed...'; +}; diff --git a/js/component/card/comparison_settings.js b/js/component/card/comparison_settings.js index 3a48190..ea444e8 100644 --- a/js/component/card/comparison_settings.js +++ b/js/component/card/comparison_settings.js @@ -1,16 +1,27 @@ /** * Home comparison settings. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for. */ -beestat.component.card.comparison_settings = function() { +beestat.component.card.comparison_settings = function(thermostat_id) { var self = this; + this.thermostat_id_ = thermostat_id; + /* - * If the thermostat_group changes that means the property_type could change - * and thus need to rerender. + * If the thermostat changes that means the property_type could change and + * thus need to rerender. */ - beestat.dispatcher.addEventListener('cache.thermostat_group', function() { - self.rerender(); - }); + beestat.dispatcher.addEventListener( + [ + 'cache.thermostat', + 'cache.data.metrics' + ], + function() { + self.rerender(); + } + ); beestat.component.card.apply(this, arguments); }; @@ -22,10 +33,7 @@ beestat.extend(beestat.component.card.comparison_settings, beestat.component.car * @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 thermostat = beestat.cache.thermostat[this.thermostat_id_]; var row; @@ -47,42 +55,57 @@ beestat.component.card.comparison_settings.prototype.decorate_contents_ = functi 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.profile === null) { - // This will show the loading screen. - self.data_available_(); + row = $.createElement('div').addClass('row'); + parent.appendChild(row); - var poll_interval = 10000; + var column_detail = $.createElement('div').addClass([ + 'column', + 'column_12' + ]); + row.appendChild(column_detail); + this.decorate_detail_(column_detail); - beestat.add_poll_interval(poll_interval); - beestat.dispatcher.addEventListener('poll.comparisons_load', function() { - if (self.data_available_() === true) { - beestat.remove_poll_interval(poll_interval); - beestat.dispatcher.removeEventListener('poll.comparisons_load'); + const sync_progress = beestat.thermostat.get_sync_progress(this.thermostat_id_); - new beestat.api() - .add_call( - 'thermostat_group', - 'generate_profiles', - {}, - 'generate_profiles' - ) - .add_call( - 'thermostat_group', - 'read_id', - {}, - 'thermostat_group' - ) - .set_callback(function(response) { - beestat.cache.set('thermostat_group', response.thermostat_group); - (new beestat.layer.comparisons()).render(); - }) - .send(); - } - }); + if (sync_progress === null || sync_progress < 100) { + this.show_loading_('Fetching'); + window.setTimeout(function() { + var api = new beestat.api(); + api.add_call( + 'thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'thermostat' + ); + + api.set_callback(function(response) { + beestat.cache.set('thermostat', response.thermostat); + }); + + api.send(); + }, 10000); + } else if (beestat.cache.data.metrics === undefined) { + this.show_loading_('Fetching'); + } else { + if (thermostat.profile === null) { + new beestat.api() + .add_call( + 'thermostat', + 'generate_profile', + { + 'thermostat_id': this.thermostat_id_ + }, + 'thermostat' + ) + .set_callback(function(response) { + beestat.cache.set('thermostat', response.thermostat); + }) + .send(); + } } }; @@ -118,6 +141,9 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function button .set_background_color(beestat.style.color.bluegray.light) .addEventListener('click', function() { + // Delete from the cache to trigger the metrics loading screen + beestat.cache.delete('data.metrics'); + // Update the setting beestat.setting('comparison_region', region); @@ -125,12 +151,21 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function self.rerender(); // Open up the loading window. - self.show_loading_('Calculating Score for ' + region + ' region'); + self.show_loading_('Fetching'); - beestat.comparisons.get_comparison_scores(function() { - // Rerender to get rid of the loader. - self.rerender(); - }); + new beestat.api() + .add_call( + 'thermostat', + 'get_metrics', + { + 'thermostat_id': self.thermostat_id_, + 'attributes': beestat.comparisons.get_attributes() + } + ) + .set_callback(function(response) { + beestat.cache.set('data.metrics', response); + }) + .send(); }); } @@ -147,10 +182,7 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function 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 - ]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; (new beestat.component.title('Property')).render(parent); @@ -160,12 +192,12 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi 'text': 'Very Similar' }); - if (thermostat_group.property_structure_type !== null) { + if (thermostat.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) + thermostat.property.structure_type.charAt(0).toUpperCase() + + thermostat.property.structure_type.slice(1) }); } @@ -191,6 +223,9 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi button .set_background_color(beestat.style.color.bluegray.light) .addEventListener('click', function() { + // Delete from the cache to trigger the metrics loading screen + beestat.cache.delete('data.metrics'); + // Update the setting beestat.setting('comparison_property_type', property_type.value); @@ -198,12 +233,21 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi self.rerender(); // Open up the loading window. - self.show_loading_('Calculating Score for ' + property_type.text); + self.show_loading_('Fetching'); - beestat.comparisons.get_comparison_scores(function() { - // Rerender to get rid of the loader. - self.rerender(); - }); + new beestat.api() + .add_call( + 'thermostat', + 'get_metrics', + { + 'thermostat_id': self.thermostat_id_, + 'attributes': beestat.comparisons.get_attributes() + } + ) + .set_callback(function(response) { + beestat.cache.set('data.metrics', response); + }) + .send(); }); } @@ -212,6 +256,55 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi button_group.render(parent); }; +beestat.component.card.comparison_settings.prototype.decorate_detail_ = function(parent) { + var strings = []; + + strings.push('Matching system type and stages'); + + var comparison_attributes = beestat.comparisons.get_attributes(); + + if (comparison_attributes.property_structure_type !== undefined) { + strings.push('Property Type: ' + this.get_comparison_string_(comparison_attributes.property_structure_type)); + } else { + strings.push('Any property type'); + } + + if (comparison_attributes.property_age !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_age, 'years old')); + } else { + strings.push('Any property age'); + } + + if (comparison_attributes.property_square_feet !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_square_feet, 'sqft')); + } else { + strings.push('Any square footage'); + } + + if (comparison_attributes.property_stories !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_stories, 'stories')); + } else { + strings.push('Any number of stories'); + } + + if (comparison_attributes.radius !== undefined) { + strings.push('Within ' + comparison_attributes.radius.value + ' miles of your location'); + } else { + strings.push('Any region'); + } + + (new beestat.component.title('Comparing to homes like...')).render(parent); + + strings.forEach(function(string) { + var div = $.createElement('div'); + div.innerText(string); + if (string.match('Any') !== null) { + div.style({'color': beestat.style.color.gray.base}); + } + parent.appendChild(div); + }); +}; + /** * Get the title of the card. * @@ -227,31 +320,19 @@ beestat.component.card.comparison_settings.prototype.get_title_ = function() { * @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]; + const thermostat = beestat.cache.thermostat[this.thermostat_id_]; + const address = beestat.cache.address[thermostat.address_id]; - var string = ''; + let string = 'Thermostat at '; if (address.normalized !== null && address.normalized.delivery_line_1 !== undefined) { - string = address.normalized.delivery_line_1; + string += address.normalized.delivery_line_1; } else if (address.normalized !== null && address.normalized.address1 !== undefined) { - string = address.normalized.address1; + string += address.normalized.address1; } else { - string = 'Unknown Address'; + 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; }; @@ -272,21 +353,32 @@ beestat.component.card.comparison_settings.prototype.decorate_top_right_ = funct }; /** - * Determine whether or not all of the data has been loaded so the scores can - * be generated. + * Helper function to display various comparison strings in a human-readable + * way. * - * @return {boolean} Whether or not all of the data has been loaded. + * @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.card.comparison_settings.prototype.data_available_ = function() { - var sync_progress = beestat.thermostat.get_sync_progress(beestat.setting('thermostat_id')); - - if (sync_progress >= 95) { - this.show_loading_('Calculating Scores'); - } else { - this.show_loading_('Syncing Data (' + - Math.round(sync_progress) + - '%)'); +beestat.component.card.comparison_settings.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 sync_progress === 100; + return comparison_attribute + s; }; diff --git a/js/component/card/early_access.js b/js/component/card/early_access.js index 80c2e85..6d2344e 100644 --- a/js/component/card/early_access.js +++ b/js/component/card/early_access.js @@ -15,13 +15,3 @@ beestat.component.card.early_access.prototype.decorate_contents_ = function(pare parent.style('background', beestat.style.color.green.base); parent.appendChild($.createElement('p').innerText('Experimental early access features below! ⤵')); }; - -/** - * Get the title of the card. - * - * @return {string} The title of the card. - */ -// beestat.component.card.early_access.prototype.get_title_ = function() { -// return 'Possible issue with your temperature profiles!'; -// }; - diff --git a/js/component/card/metrics.js b/js/component/card/metrics.js index 0db362f..2ea78f8 100644 --- a/js/component/card/metrics.js +++ b/js/component/card/metrics.js @@ -1,8 +1,11 @@ /** * Metrics card. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for. */ -beestat.component.card.metrics = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; +beestat.component.card.metrics = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; var self = this; @@ -11,58 +14,153 @@ beestat.component.card.metrics = function(thermostat_group_id) { * event. This fires on the trailing edge so that all changes are accounted * for when rerendering. */ - var data_change_function = beestat.debounce(function() { + var change_function = beestat.debounce(function() { self.rerender(); }, 10); beestat.dispatcher.addEventListener( - 'cache.data.metrics', - data_change_function + [ + 'cache.data.metrics', + 'cache.thermostat' + ], + change_function ); beestat.component.card.apply(this, arguments); - // this.layer_.register_loader(beestat.comparisons.get_comparison_metricss); + new beestat.api() + .add_call( + 'thermostat', + 'get_metrics', + { + 'thermostat_id': this.thermostat_id_, + 'attributes': beestat.comparisons.get_attributes() + } + ) + .set_callback(function(response) { + beestat.cache.set('data.metrics', response); + }) + .send(); }; beestat.extend(beestat.component.card.metrics, beestat.component.card); +beestat.component.card.metrics.prototype.rerender_on_breakpoint_ = true; + /** * Decorate * * @param {rocket.Elements} parent */ beestat.component.card.metrics.prototype.decorate_contents_ = function(parent) { - var self = this; + if (beestat.cache.data.metrics === undefined) { + parent.appendChild($.createElement('div').style('height', '100px')); + this.show_loading_('Fetching'); + } else { + /** + * An entry for every possible metric is always returned for clarity. + * Remove the children with no data and then the parents with no children. + */ + this.filtered_metrics_ = {}; + let metric_count = 0; + for (const parent_metric_name in beestat.cache.data.metrics) { + for (const child_metric_name in beestat.cache.data.metrics[parent_metric_name]) { + if (beestat.cache.data.metrics[parent_metric_name][child_metric_name] !== null) { + if (this.filtered_metrics_[parent_metric_name] === undefined) { + this.filtered_metrics_[parent_metric_name] = {}; + } + this.filtered_metrics_[parent_metric_name][child_metric_name] = beestat.cache.data.metrics[parent_metric_name][child_metric_name]; + metric_count++; + } + } + } - var metrics = [ - 'setpoint_heat', - 'setpoint_cool', - // 'runtime_per_heating_degree_day' - ]; + if (metric_count === 0) { + this.decorate_empty_(parent); + } else { + let column_count = 1; + if (beestat.width > 1000) { + column_count = 3; + } else if (beestat.width > 800) { + column_count = 2; + } + const column_span = 12 / column_count; - // Decorate the metrics - var metric_container = $.createElement('div') - .style({ - 'display': 'grid', - // 'grid-template-columns': 'repeat(auto-fit, minmax(160px, 1fr))', - 'grid-template-columns': '1fr 1fr 1fr', - 'margin': '0 0 ' + beestat.style.size.gutter + 'px -' + beestat.style.size.gutter + 'px' + const columns = []; + const row = $.createElement('div').addClass('row'); + parent.appendChild(row); + + for (let i = 0; i < column_count; i++) { + const column = $.createElement('div') + .addClass([ + 'column', + 'column_' + column_span + ]); + row.appendChild(column); + columns.push({ + 'size': 0, + 'element': column + }); + } + + const get_smallest_column = function() { + let smallest_column = columns[0]; + columns.forEach(function(column) { + if (column.size < smallest_column.size) { + smallest_column = column; + } + }); + + return smallest_column; + }; + + for (const parent_metric_name in this.filtered_metrics_) { + const group_size = Object.keys(this.filtered_metrics_[parent_metric_name]).length; + const smallest_column = get_smallest_column(); + this.decorate_group_(smallest_column.element, parent_metric_name); + smallest_column.size += group_size; + } + } + } +}; + +/** + * Put a message in there if no data is present. + * + * @param {rocket.Elements} parent Parent + */ +beestat.component.card.metrics.prototype.decorate_empty_ = function(parent) { + parent.appendChild($.createElement('p').innerText('We couldn\'t generate any metrics for your system. Try broadening your comparison settings and ensuring your system type and thermostat address are properly set.')); +}; + +/** + * Decorate a group of metrics. + * + * @param {rocket.Elements} parent Parent + * @param {string} parent_metric_name The name of the group. + */ +beestat.component.card.metrics.prototype.decorate_group_ = function(parent, parent_metric_name) { + const parent_metric = this.filtered_metrics_[parent_metric_name]; + + const title = parent_metric_name + .replace(/_/g, ' ') + .replace(/^(.)|\s+(.)/g, function($1) { + return $1.toUpperCase(); }); + + parent.appendChild($.createElement('p').innerText(title)); + + const metric_container = $.createElement('div'); parent.appendChild(metric_container); - metrics.forEach(function(metric) { - var div = $.createElement('div') + for (const child_metric_name in parent_metric) { + const div = $.createElement('div') .style({ - 'padding': beestat.style.size.gutter + 'px 0 0 ' + beestat.style.size.gutter + 'px' + 'background': beestat.style.color.bluegray.dark, + 'margin-bottom': beestat.style.size.gutter / 4 }); metric_container.appendChild(div); - - (new beestat.component.metric[metric](self.thermostat_group_id_)).render(div); - }); - - - - + (new beestat.component.metric[parent_metric_name][child_metric_name](this.thermostat_id_)).render(div); + } }; /** @@ -79,6 +177,44 @@ beestat.component.card.metrics.prototype.get_title_ = function() { * * @param {rocket.Elements} parent */ -/*beestat.component.card.my_home.prototype.decorate_top_right_ = function(parent) { +beestat.component.card.metrics.prototype.decorate_top_right_ = function(parent) { + const 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() { + window.open('https://doc.beestat.io/ebfdf00f7f34436c980cd6344a767a12'); + })); +}; + +/** + * Get the subtitle of the card. + * + * @return {string} The subtitle. + */ +beestat.component.card.metrics.prototype.get_subtitle_ = function() { + const thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + const generated_at_m = moment( + thermostat.profile.metadata.generated_at + ); + + let duration_text = ''; + + // How much data was used to generate this. + const duration_weeks = Math.round(thermostat.profile.metadata.duration / 7); + duration_text += ' from the past '; + if (duration_weeks === 0) { + duration_text += ' few days'; + } else if (duration_weeks === 1) { + duration_text += ' week'; + } else if (duration_weeks >= 52) { + duration_text += ' year'; + } else { + duration_text += duration_weeks + ' weeks'; + } + duration_text += ' of data.'; + + return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly) ' + duration_text; +}; diff --git a/js/component/card/my_home.js b/js/component/card/my_home.js index c73e573..8d21f10 100644 --- a/js/component/card/my_home.js +++ b/js/component/card/my_home.js @@ -1,9 +1,15 @@ /** * Home properties. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for. */ -beestat.component.card.my_home = function() { +beestat.component.card.my_home = function(thermostat_id) { var self = this; - beestat.dispatcher.addEventListener('cache.thermostat_group', function() { + + this.thermostat_id_ = thermostat_id; + + beestat.dispatcher.addEventListener('cache.thermostat', function() { self.rerender(); }); @@ -23,10 +29,7 @@ beestat.component.card.my_home.prototype.decorate_contents_ = function(parent) { * @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 - ]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; (new beestat.component.title('System')).render(parent); @@ -72,9 +75,8 @@ beestat.component.card.my_home.prototype.decorate_system_type_ = function(parent * @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]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; + var address = beestat.cache.address[thermostat.address_id]; (new beestat.component.title('Region')).render(parent); @@ -123,29 +125,28 @@ beestat.component.card.my_home.prototype.decorate_region_ = function(parent) { * @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]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; (new beestat.component.title('Property')).render(parent); var button_group = new beestat.component.button_group(); - if (thermostat_group.property_structure_type !== null) { + if (thermostat.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))); + .set_text(thermostat.property.structure_type.charAt(0).toUpperCase() + + thermostat.property.structure_type.slice(1))); } if ( - thermostat_group.property_stories !== null && + thermostat.property.stories !== null && ( - thermostat_group.property_structure_type === 'detached' || - thermostat_group.property_structure_type === 'townhouse' || - thermostat_group.property_structure_type === 'semi-detached' + thermostat.property.structure_type === 'detached' || + thermostat.property.structure_type === 'townhouse' || + thermostat.property.structure_type === 'semi-detached' ) ) { button_group.add_button(new beestat.component.button() @@ -153,26 +154,26 @@ beestat.component.card.my_home.prototype.decorate_property_ = function(parent) { .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'))); + .set_text(thermostat.property.stories + + (thermostat.property.stories === 1 ? ' Story' : ' Stories'))); } - if (thermostat_group.property_square_feet !== null) { + if (thermostat.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')); + .set_text(Number(thermostat.property.square_feet).toLocaleString() + ' sqft')); } - if (thermostat_group.property_age !== null) { + if (thermostat.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')); + .set_text(thermostat.property.age + ' Years')); } if (button_group.get_buttons().length === 0) { @@ -202,13 +203,15 @@ beestat.component.card.my_home.prototype.get_title_ = function() { * @param {rocket.Elements} parent */ beestat.component.card.my_home.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('Change System Type') .set_icon('tune') .set_callback(function() { - (new beestat.component.modal.change_system_type()).render(); + (new beestat.component.modal.change_system_type(self.thermostat_id_)).render(); })); menu.add_menu_item(new beestat.component.menu_item() diff --git a/js/component/card/runtime_sensor_detail.js b/js/component/card/runtime_sensor_detail.js index 08ce90b..f24417e 100644 --- a/js/component/card/runtime_sensor_detail.js +++ b/js/component/card/runtime_sensor_detail.js @@ -27,8 +27,8 @@ beestat.component.card.runtime_sensor_detail = function(thermostat_id) { [ 'setting.runtime_sensor_detail_range_type', 'setting.runtime_sensor_detail_range_dynamic', - 'cache.runtime_thermostat', - 'cache.runtime_sensor' + 'cache.data.runtime_thermostat_sensor_detail', + 'cache.data.runtime_sensor' ], change_function ); @@ -131,11 +131,8 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func * the database, check every 2 seconds until it does. */ if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { - if ( - beestat.cache.runtime_sensor === undefined || - beestat.cache.data.runtime_thermostat_last !== 'runtime_sensor_detail' - ) { - this.show_loading_('Loading'); + if (beestat.cache.runtime_sensor === undefined) { + this.show_loading_('Fetching'); var value; var operator; @@ -191,8 +188,7 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func for (var alias in response) { var r = response[alias]; if (alias === 'runtime_thermostat') { - beestat.cache.set('data.runtime_thermostat_last', 'runtime_sensor_detail'); - beestat.cache.set('runtime_thermostat', r); + beestat.cache.set('data.runtime_thermostat_sensor_detail', r); } else { runtime_sensors = runtime_sensors.concat(r); } @@ -299,12 +295,12 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun } })); - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Custom') - .set_icon('calendar_edit') - .set_callback(function() { - (new beestat.component.modal.runtime_sensor_detail_custom()).render(); - })); + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Custom') + .set_icon('calendar_edit') + .set_callback(function() { + (new beestat.component.modal.runtime_sensor_detail_custom()).render(); + })); if (this.has_data_() === true) { menu.add_menu_item(new beestat.component.menu_item() @@ -359,7 +355,6 @@ beestat.component.card.runtime_sensor_detail.prototype.has_data_ = function() { */ beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function(force) { if (this.data_ === undefined || force === true) { - var range = { 'type': beestat.setting('runtime_sensor_detail_range_type'), 'dynamic': beestat.setting('runtime_sensor_detail_range_dynamic'), @@ -368,7 +363,11 @@ beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function(forc }; var sensor_data = beestat.runtime_sensor.get_data(this.thermostat_id_, range); - var thermostat_data = beestat.runtime_thermostat.get_data(this.thermostat_id_, range); + var thermostat_data = beestat.runtime_thermostat.get_data( + this.thermostat_id_, + range, + 'runtime_thermostat_sensor_detail' + ); this.data_ = sensor_data; diff --git a/js/component/card/runtime_thermostat_detail.js b/js/component/card/runtime_thermostat_detail.js index 20003b3..a491aa7 100644 --- a/js/component/card/runtime_thermostat_detail.js +++ b/js/component/card/runtime_thermostat_detail.js @@ -27,8 +27,8 @@ beestat.component.card.runtime_thermostat_detail = function(thermostat_id) { [ 'setting.runtime_thermostat_detail_range_type', 'setting.runtime_thermostat_detail_range_dynamic', - 'cache.runtime_thermostat', - 'cache.thermostat' + 'cache.data.runtime_thermostat_thermostat_detail', + 'cache.data.thermostat' ], change_function ); @@ -150,11 +150,8 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = * the database, check every 2 seconds until it does. */ if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) { - if ( - beestat.cache.runtime_thermostat === undefined || - beestat.cache.data.runtime_thermostat_last !== 'runtime_thermostat_detail' - ) { - this.show_loading_('Loading'); + if (beestat.cache.data.runtime_thermostat_thermostat_detail === undefined) { + this.show_loading_('Fetching'); var value; var operator; @@ -185,8 +182,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = } ) .set_callback(function(response) { - beestat.cache.set('data.runtime_thermostat_last', 'runtime_thermostat_detail'); - beestat.cache.set('runtime_thermostat', response); + beestat.cache.set('data.runtime_thermostat_thermostat_detail', response); }) .send(); } else if (this.has_data_() === false) { @@ -246,7 +242,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 1 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_thermostat'); + beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 1, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -262,7 +258,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 3 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_thermostat'); + beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 3, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -278,7 +274,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = beestat.setting('runtime_thermostat_detail_range_dynamic') !== 7 || beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { - beestat.cache.delete('runtime_thermostat'); + beestat.cache.delete('data.runtime_thermostat_thermostat_detail'); beestat.setting({ 'runtime_thermostat_detail_range_dynamic': 7, 'runtime_thermostat_detail_range_type': 'dynamic' @@ -353,7 +349,11 @@ beestat.component.card.runtime_thermostat_detail.prototype.get_data_ = function( 'static_end': beestat.setting('runtime_thermostat_detail_range_static_end') }; - this.data_ = beestat.runtime_thermostat.get_data(this.thermostat_id_, range); + this.data_ = beestat.runtime_thermostat.get_data( + this.thermostat_id_, + range, + 'runtime_thermostat_thermostat_detail' + ); this.data_.metadata.chart.title = this.get_title_(); this.data_.metadata.chart.subtitle = this.get_subtitle_(); diff --git a/js/component/card/score.js b/js/component/card/score.js deleted file mode 100644 index 9ed45ca..0000000 --- a/js/component/card/score.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * 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_scores_' + this.type_, - data_change_function - ); - - beestat.component.card.apply(this, arguments); - - this.layer_.register_loader(beestat.comparisons.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) { - 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_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 - ) { - percentile = this.get_percentile_( - thermostat_group.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); - } -}; - -/** - * 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('Help') - .set_icon('help_circle') - .set_callback(function() { - window.open('https://doc.beestat.io/144d5dafbc6c43f7bc72341120717d8a'); - })); -}; - -/** - * Get subtitle. - * - * @return {string} The subtitle. - */ -beestat.component.card.score.prototype.get_subtitle_ = function() { - 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_scores_' + this.type_] !== undefined && - beestat.cache.data['comparison_scores_' + this.type_].length > 2 && - thermostat_group.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 deleted file mode 100644 index 19130b8..0000000 --- a/js/component/card/score/cool.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6a1043f..0000000 --- a/js/component/card/score/heat.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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 deleted file mode 100644 index bac740d..0000000 --- a/js/component/card/score/resist.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * 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 index 663216d..1760d34 100644 --- a/js/component/card/sensors.js +++ b/js/component/card/sensors.js @@ -162,7 +162,7 @@ beestat.component.card.sensors.prototype.decorate_sensor_ = function(parent, sen /** * Get the title of the card. * - * @return {string} + * @return {string} The title. */ beestat.component.card.sensors.prototype.get_title_ = function() { return 'Sensors'; diff --git a/js/component/card/system.js b/js/component/card/system.js index 6e370ac..191630d 100644 --- a/js/component/card/system.js +++ b/js/component/card/system.js @@ -87,6 +87,11 @@ beestat.component.card.system.prototype.decorate_circle_ = function(parent) { ); }; +/** + * Decorate the weather + * + * @param {rocket.Elements} parent Parent + */ beestat.component.card.system.prototype.decorate_weather_ = function(parent) { var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js index b8beaac..0a58daa 100644 --- a/js/component/card/temperature_profiles.js +++ b/js/component/card/temperature_profiles.js @@ -1,11 +1,11 @@ /** * Temperature profiles. * - * @param {number} thermostat_group_id The thermostat_group_id this card is - * displaying data for. + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for. */ -beestat.component.card.temperature_profiles = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; +beestat.component.card.temperature_profiles = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; beestat.component.card.apply(this, arguments); }; @@ -28,9 +28,7 @@ beestat.component.card.temperature_profiles.prototype.decorate_contents_ = funct * @return {object} The series data. */ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { - var thermostat_group = beestat.cache.thermostat_group[ - this.thermostat_group_id_ - ]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; var data = { 'x': [], @@ -41,7 +39,7 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { 'title': this.get_title_(), 'subtitle': this.get_subtitle_(), 'outdoor_temperature': beestat.temperature({ - 'temperature': (thermostat_group.weather.temperature / 10), + 'temperature': (thermostat.weather.temperature / 10), 'round': 0 }) } @@ -49,10 +47,10 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { }; if ( - thermostat_group.temperature_profile === null + thermostat.profile === null ) { this.chart_.render(parent); - this.show_loading_('Calculating'); + this.show_loading_('Fetching'); } else { // Global x range. var x_min = Infinity; @@ -60,19 +58,19 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { var y_min = Infinity; var y_max = -Infinity; - for (var type in thermostat_group.temperature_profile) { + for (var type in thermostat.profile.temperature) { // Cloned because I mutate this data for temperature conversions. var profile = beestat.clone( - thermostat_group.temperature_profile[type] + thermostat.profile.temperature[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 / 10)})] = + deltas_converted[beestat.temperature({'temperature': key})] = beestat.temperature({ - 'temperature': (profile.deltas[key] / 10), + 'temperature': (profile.deltas[key]), 'delta': true, 'round': 3 }); @@ -199,24 +197,29 @@ beestat.component.card.temperature_profiles.prototype.get_title_ = function() { * @return {string} The subtitle. */ beestat.component.card.temperature_profiles.prototype.get_subtitle_ = function() { - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; - var thermostat_group = beestat.cache.thermostat_group[ - thermostat.thermostat_group_id - ]; + const thermostat = beestat.cache.thermostat[this.thermostat_id_]; - /* - * All profiles are calculated at the same time, and resist is the most - * guaranteed one to have. - */ - if (thermostat_group.temperature_profile.resist !== undefined) { - var generated_at_m = moment( - thermostat_group.temperature_profile.resist.metadata.generated_at - ); + const generated_at_m = moment( + thermostat.profile.metadata.generated_at + ); - return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly)'; + let duration_text = ''; + + // How much data was used to generate this. + const duration_weeks = Math.round(thermostat.profile.metadata.duration / 7); + duration_text += ' from the past '; + if (duration_weeks === 0) { + duration_text += ' few days'; + } else if (duration_weeks === 1) { + duration_text += ' week'; + } else if (duration_weeks >= 52) { + duration_text += ' year'; + } else { + duration_text += duration_weeks + ' weeks'; } + duration_text += ' of data.'; - return null; + return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly) ' + duration_text; }; /** diff --git a/js/component/card/temperature_profiles_new.js b/js/component/card/temperature_profiles_new.js deleted file mode 100644 index 66c44df..0000000 --- a/js/component/card/temperature_profiles_new.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Temperature profiles. - * - * @param {number} thermostat_group_id The thermostat_group_id this card is - * displaying data for. - */ -beestat.component.card.temperature_profiles_new = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; - - beestat.component.card.apply(this, arguments); -}; -beestat.extend(beestat.component.card.temperature_profiles_new, beestat.component.card); - -/** - * Decorate card. - * - * @param {rocket.Elements} parent - */ -beestat.component.card.temperature_profiles_new.prototype.decorate_contents_ = function(parent) { - var data = this.get_data_(); - this.chart_ = new beestat.component.chart.temperature_profiles_new(data); - this.chart_.render(parent); -}; - -/** - * Get all of the series data. - * - * @return {object} The series data. - */ -beestat.component.card.temperature_profiles_new.prototype.get_data_ = function() { - var thermostat_group = beestat.cache.thermostat_group[ - this.thermostat_group_id_ - ]; - - var data = { - 'x': [], - 'series': {}, - 'metadata': { - 'series': {}, - 'chart': { - 'title': this.get_title_(), - 'subtitle': this.get_subtitle_(), - 'outdoor_temperature': beestat.temperature({ - 'temperature': (thermostat_group.weather.temperature / 10), - 'round': 0 - }) - } - } - }; - - if ( - thermostat_group.profile === null - ) { - this.chart_.render(parent); - this.show_loading_('Calculating'); - } else { - // Global x range. - var x_min = Infinity; - var x_max = -Infinity; - - var y_min = Infinity; - var y_max = -Infinity; - for (var type in thermostat_group.profile.temperature) { - // Cloned because I mutate this data for temperature conversions. - var profile = beestat.clone( - thermostat_group.profile.temperature[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); - - data.series['trendline_' + type] = []; - data.series['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 (beestat.setting('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; - - data.series['trendline_' + type].push([ - parseFloat(x_fixed), - y - ]); - if (profile.deltas[x_fixed] !== undefined) { - data.series['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]); - } - - data.metadata.chart.y_min = y_min; - data.metadata.chart.y_max = y_max; - } - } - } - } - - return data; -}; - -/** - * 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_new.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_new.prototype.get_title_ = function() { - return 'Temperature Profiles'; -}; - -/** - * Get the subtitle of the card. - * - * @return {string} The subtitle. - */ -beestat.component.card.temperature_profiles_new.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 generated_at_m = moment( - thermostat_group.profile.metadata.generated_at - ); - - return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly)'; -}; - -/** - * Decorate the menu. - * - * @param {rocket.Elements} parent - */ -beestat.component.card.temperature_profiles_new.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_.export(); - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Help') - .set_icon('help_circle') - .set_callback(function() { - window.open('https://doc.beestat.io/9c0fba6793dd4bc68f798c1516f0ea25'); - })); -}; diff --git a/js/component/chart/runtime_sensor_detail_temperature.js b/js/component/chart/runtime_sensor_detail_temperature.js index eed4e10..8212555 100644 --- a/js/component/chart/runtime_sensor_detail_temperature.js +++ b/js/component/chart/runtime_sensor_detail_temperature.js @@ -229,7 +229,7 @@ beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_ }); var occupancy_key = series.name.replace('temperature', 'occupancy'); occupancy[occupancy_key] = - (self.data_.metadata.series[occupancy_key].data[x.valueOf()] !== undefined) + (self.data_.metadata.series[occupancy_key].data[x.valueOf()] !== undefined); } }); diff --git a/js/component/chart/temperature_profiles.js b/js/component/chart/temperature_profiles.js index 35e6f84..669cdbc 100644 --- a/js/component/chart/temperature_profiles.js +++ b/js/component/chart/temperature_profiles.js @@ -31,9 +31,9 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun // Trendline data series.push({ - 'data': this.data_.series.trendline_heat, - 'name': 'indoor_heat_delta', - 'color': beestat.series.compressor_heat_1.color, + 'data': this.data_.series.trendline_heat_1, + 'name': 'indoor_heat_1_delta', + 'color': beestat.series.indoor_heat_1_delta.color, 'marker': { 'enabled': false, 'states': {'hover': {'enabled': false}} @@ -45,9 +45,37 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun // Trendline data series.push({ - 'data': this.data_.series.trendline_cool, - 'name': 'indoor_cool_delta', - 'color': beestat.series.compressor_cool_1.color, + 'data': this.data_.series.trendline_heat_2, + 'name': 'indoor_heat_2_delta', + 'color': beestat.series.indoor_heat_2_delta.color, + 'marker': { + 'enabled': false, + 'states': {'hover': {'enabled': false}} + }, + 'type': 'line', + 'lineWidth': 2, + 'states': {'hover': {'lineWidthPlus': 0}} + }); + + // Trendline data + series.push({ + 'data': this.data_.series.trendline_cool_1, + 'name': 'indoor_cool_1_delta', + 'color': beestat.series.indoor_cool_1_delta.color, + 'marker': { + 'enabled': false, + 'states': {'hover': {'enabled': false}} + }, + 'type': 'line', + 'lineWidth': 2, + 'states': {'hover': {'lineWidthPlus': 0}} + }); + + // Trendline data + series.push({ + 'data': this.data_.series.trendline_cool_2, + 'name': 'indoor_cool_2_delta', + 'color': beestat.series.indoor_cool_2_delta.color, 'marker': { 'enabled': false, 'states': {'hover': {'enabled': false}} @@ -61,7 +89,7 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun series.push({ 'data': this.data_.series.trendline_resist, 'name': 'indoor_resist_delta', - 'color': beestat.style.color.gray.dark, + 'color': beestat.series.indoor_resist_delta.color, 'marker': { 'enabled': false, 'states': {'hover': {'enabled': false}} @@ -73,9 +101,9 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun // Raw data series.push({ - 'data': this.data_.series.raw_heat, - 'name': 'indoor_heat_delta_raw', - 'color': beestat.series.compressor_heat_1.color, + 'data': this.data_.series.raw_heat_1, + 'name': 'indoor_heat_1_delta_raw', + 'color': beestat.series.indoor_heat_1_delta_raw.color, 'dashStyle': 'ShortDot', 'marker': { 'enabled': false, @@ -88,9 +116,39 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun // Raw data series.push({ - 'data': this.data_.series.raw_cool, - 'name': 'indoor_cool_delta_raw', - 'color': beestat.series.compressor_cool_1.color, + 'data': this.data_.series.raw_heat_2, + 'name': 'indoor_heat_2_delta_raw', + 'color': beestat.series.indoor_heat_2_delta_raw.color, + 'dashStyle': 'ShortDot', + 'marker': { + 'enabled': false, + 'states': {'hover': {'enabled': false}} + }, + 'type': 'spline', + 'lineWidth': 1, + 'states': {'hover': {'lineWidthPlus': 0}} + }); + + // Raw data + series.push({ + 'data': this.data_.series.raw_cool_1, + 'name': 'indoor_cool_1_delta_raw', + 'color': beestat.series.indoor_cool_1_delta_raw.color, + 'dashStyle': 'ShortDot', + 'marker': { + 'enabled': false, + 'states': {'hover': {'enabled': false}} + }, + 'type': 'spline', + 'lineWidth': 1, + 'states': {'hover': {'lineWidthPlus': 0}} + }); + + // Raw data + series.push({ + 'data': this.data_.series.raw_cool_2, + 'name': 'indoor_cool_2_delta_raw', + 'color': beestat.series.indoor_cool_2_delta_raw.color, 'dashStyle': 'ShortDot', 'marker': { 'enabled': false, @@ -105,7 +163,7 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun series.push({ 'data': this.data_.series.raw_resist, 'name': 'indoor_resist_delta_raw', - 'color': beestat.style.color.gray.dark, + 'color': beestat.series.indoor_resist_delta_raw.color, 'dashStyle': 'ShortDot', 'marker': { 'enabled': false, diff --git a/js/component/chart/temperature_profiles_new.js b/js/component/chart/temperature_profiles_new.js deleted file mode 100644 index 5adb56e..0000000 --- a/js/component/chart/temperature_profiles_new.js +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Temperature profiles chart. - * - * @param {object} data The chart data. - */ -beestat.component.chart.temperature_profiles_new = function(data) { - this.data_ = data; - - beestat.component.chart.apply(this, arguments); -}; -beestat.extend(beestat.component.chart.temperature_profiles_new, beestat.component.chart); - -/** - * Override for get_options_xAxis_labels_formatter_. - * - * @return {Function} xAxis labels formatter. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_xAxis_labels_formatter_ = function() { - return function() { - return this.value + beestat.setting('temperature_unit'); - }; -}; - -/** - * Override for get_options_series_. - * - * @return {Array} All of the series to display on the chart. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_series_ = function() { - var series = []; - - // Trendline data - series.push({ - 'data': this.data_.series.trendline_heat_1, - 'name': 'indoor_heat_1_delta', - 'color': beestat.series.indoor_heat_1_delta.color, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'line', - 'lineWidth': 2, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Trendline data - series.push({ - 'data': this.data_.series.trendline_heat_2, - 'name': 'indoor_heat_2_delta', - 'color': beestat.series.indoor_heat_2_delta.color, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'line', - 'lineWidth': 2, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Trendline data - series.push({ - 'data': this.data_.series.trendline_cool_1, - 'name': 'indoor_cool_1_delta', - 'color': beestat.series.indoor_cool_1_delta.color, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'line', - 'lineWidth': 2, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Trendline data - series.push({ - 'data': this.data_.series.trendline_cool_2, - 'name': 'indoor_cool_2_delta', - 'color': beestat.series.indoor_cool_2_delta.color, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'line', - 'lineWidth': 2, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Trendline data - series.push({ - 'data': this.data_.series.trendline_resist, - 'name': 'indoor_resist_delta', - 'color': beestat.series.indoor_resist_delta.color, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'line', - 'lineWidth': 2, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Raw data - series.push({ - 'data': this.data_.series.raw_heat_1, - 'name': 'indoor_heat_1_delta_raw', - 'color': beestat.series.indoor_heat_1_delta_raw.color, - 'dashStyle': 'ShortDot', - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Raw data - series.push({ - 'data': this.data_.series.raw_heat_2, - 'name': 'indoor_heat_2_delta_raw', - 'color': beestat.series.indoor_heat_2_delta_raw.color, - 'dashStyle': 'ShortDot', - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Raw data - series.push({ - 'data': this.data_.series.raw_cool_1, - 'name': 'indoor_cool_1_delta_raw', - 'color': beestat.series.indoor_cool_1_delta_raw.color, - 'dashStyle': 'ShortDot', - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Raw data - series.push({ - 'data': this.data_.series.raw_cool_2, - 'name': 'indoor_cool_2_delta_raw', - 'color': beestat.series.indoor_cool_2_delta_raw.color, - 'dashStyle': 'ShortDot', - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - // Raw data - series.push({ - 'data': this.data_.series.raw_resist, - 'name': 'indoor_resist_delta_raw', - 'color': beestat.series.indoor_resist_delta_raw.color, - 'dashStyle': 'ShortDot', - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - return series; -}; - -/** - * Override for get_options_yAxis_. - * - * @return {Array} The y-axis options. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_yAxis_ = function() { - var absolute_y_max = Math.max( - Math.abs(this.data_.metadata.chart.y_min), - Math.abs(this.data_.metadata.chart.y_max) - ); - - var y_min = absolute_y_max * -1; - var y_max = absolute_y_max; - - return [ - { - '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 + beestat.setting('temperature_unit'); - } - }, - 'min': y_min, - 'max': y_max, - 'plotLines': [ - { - 'color': beestat.style.color.bluegray.light, - 'dashStyle': 'solid', - 'width': 3, - 'value': 0, - 'zIndex': 1 - } - ] - } - ]; -}; - -/** - * Override for get_options_tooltip_formatter_. - * - * @return {Function} The tooltip formatter. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_tooltip_formatter_ = function() { - var self = this; - - return 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' - }) + ' / h'; - - if (series.name.indexOf('raw') === -1) { - section.push({ - 'label': beestat.series[series.name].name, - 'value': value, - 'color': series.color - }); - } - }); - sections.push(section); - - return self.tooltip_formatter_helper_( - 'Outdoor Temp: ' + - beestat.temperature({ - 'temperature': this.x, - 'round': 0, - 'units': true, - 'convert': false - }), - sections - ); - }; -}; - -/** - * Override for get_options_chart_zoomType_. - * - * @return {string} The zoom type. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_chart_zoomType_ = function() { - return null; -}; - -/** - * Override for get_options_legend_. - * - * @return {object} The legend options. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_legend_ = function() { - return { - 'enabled': false - }; -}; - -/** - * Override for get_options_xAxis_. - * - * @return {object} The xAxis options. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_xAxis_ = function() { - return { - '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': this.get_options_xAxis_labels_formatter_() - }, - 'crosshair': this.get_options_xAxis_crosshair_(), - 'plotLines': [ - { - 'color': beestat.series.outdoor_temperature.color, - 'dashStyle': 'ShortDash', - 'width': 1, - 'label': { - 'style': { - 'color': beestat.series.outdoor_temperature.color - }, - 'useHTML': true, - 'text': 'Now: ' + beestat.temperature({ - 'temperature': this.data_.metadata.chart.outdoor_temperature, - 'convert': false, - 'units': true, - 'round': 0 - }) - }, - 'value': this.data_.metadata.chart.outdoor_temperature, - 'zIndex': 2 - } - ] - }; -}; - -/** - * Override for get_options_chart_height_. - * - * @return {number} The height of the chart. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_chart_height_ = function() { - return 300; -}; - -/** - * Override for get_options_plotOptions_series_connectNulls_. - * - * @return {boolean} Whether or not to connect nulls. - */ -beestat.component.chart.temperature_profiles_new.prototype.get_options_plotOptions_series_connectNulls_ = function() { - return true; -}; diff --git a/js/component/metric.js b/js/component/metric.js index a762cf6..13f2944 100644 --- a/js/component/metric.js +++ b/js/component/metric.js @@ -6,130 +6,415 @@ beestat.component.metric = function() { }; beestat.extend(beestat.component.metric, beestat.component); +/** + * Whether or not this is a temperature value. If so, do the appropriate + * conversion on display. + * + * @type {boolean} + */ +beestat.component.metric.prototype.is_temperature_ = false; + +/** + * Whether or not this temperature value is a delta instead of an absolute + * value. If so, do the appropriate conversion on display. + * + * @type {boolean} + */ +beestat.component.metric.prototype.is_temperature_delta_ = false; + /** * Decorate * * @param {rocket.Elements} parent */ beestat.component.metric.prototype.decorate_ = function(parent) { - if (beestat.cache.data.metrics === undefined) { // todo - parent.appendChild($.createElement('div').innerText('Loading...')); - return; - } + const self = this; + const metric = this.get_metric_(); - var outer_container = $.createElement('div').style({ - 'background': beestat.style.color.bluegray.dark, - 'padding': (beestat.style.size.gutter / 2) + // Construct the table + var table = $.createElement('table').style('width', '100%'); + table.setAttribute({ + 'cellpadding': '0', + 'cellspacing': '0' }); + parent.appendChild(table); - outer_container.appendChild( - $.createElement('div').innerText(this.get_title_()) + var tr = $.createElement('tr'); + table.appendChild(tr); + var td_icon = $.createElement('td') + .style({ + 'width': '36px', + 'text-align': 'center', + 'background': this.get_color_() + }); + tr.appendChild(td_icon); + + var td_title = $.createElement('td') + .style({ + 'width': '100px', + 'background': this.get_color_(), + 'color': '#fff' + }); + tr.appendChild(td_title); + + var td_chart = $.createElement('td') + .style({ + 'text-align': 'right' + }); + tr.appendChild(td_chart); + + // Fill in the content. + (new beestat.component.icon(this.get_icon_())) + .set_color('#fff') + .render(td_icon); + + td_title.appendChild($.createElement('div').innerText(this.get_title_() + ' ')); + + td_title.appendChild( + $.createElement('div') + .innerText(this.get_histogram_sum_().toLocaleString() + ' others') ); - var inner_container = $.createElement('div').style({ + var chart_container = $.createElement('div').style({ 'position': 'relative', - 'margin-top': '50px', - 'margin-bottom': '20px', - 'margin-left': '25px' + 'height': '60px' }); + td_chart.appendChild(chart_container); - var icon = $.createElement('div').style({ - 'position': 'absolute', - 'top': '-12px', - 'left': '-28px' - }); - - (new beestat.component.icon(this.get_icon_())) - .set_color(this.get_color_()) - .render(icon); - - var line = $.createElement('div').style({ - 'background': this.get_color_(), - 'height': '5px', - 'border-radius': '5px' - }); - - var min = $.createElement('div') - .innerText(this.get_min_(true)) - .style({ - 'position': 'absolute', - 'top': '10px', - 'left': '0px' - }); - - var max = $.createElement('div') - .innerText(this.get_max_(true)) - .style({ - 'position': 'absolute', - 'top': '10px', - 'right': '0px' - }); - - var label = $.createElement('div') - .innerText(this.get_value_()) - .style({ - 'position': 'absolute', - 'top': '-25px', - 'left': this.get_marker_position_() + '%', - 'width': '100px', - 'text-align': 'center', - 'margin-left': '-50px', - 'font-weight': beestat.style.font_weight.bold - }); - - var circle = $.createElement('div').style({ - 'background': this.get_color_(), - 'position': 'absolute', - 'top': '-4px', - 'left': this.get_marker_position_() + '%', - 'margin-left': '-7px', - 'width': '14px', - 'height': '14px', - 'border-radius': '50%' - }); - - var chart = $.createElement('div').style({ - 'position': 'absolute', - 'top': '-40px', - 'left': '0px', - 'width': '100%', - 'height': '40px' + var formatter = this.get_formatter_(); + + const chart_height = 60; + const chart_padding = 20; + const chart = $.createElement('div').style({ + 'height': chart_height + 'px', + 'padding-top': chart_padding + 'px', + 'position': 'relative' }); + chart_container.appendChild(chart); var histogram = this.get_histogram_(); - var histogram_max = this.get_histogram_max_(); - var column_width = (100 / histogram.length) + '%'; - histogram.forEach(function(data) { - var column = $.createElement('div').style({ + var histogram_mode = this.get_histogram_mode_(); + var column_width = (100 / histogram.length); + + let my_column_index; + let my_column_height; + let sum_less = 0; + let sum_more = 0; + histogram.forEach(function(data, i) { + const height = (data.count / histogram_mode * 100); + const column = $.createElement('div').style({ 'display': 'inline-block', - 'background': 'rgba(255, 255, 255, 0.1)', - 'width': column_width, - 'height': (data.count / histogram_max * 100) + '%' + 'width': column_width + '%', + 'height': height + '%' }); + + const value = self.get_value_(); + if ( + value >= data.value && + value < data.value + metric.interval + ) { + column.style({ + 'background': '#516169', + 'position': 'relative', + + // Makes it visible even if it's 0px high. + 'border-bottom': '2px solid #516169' + }); + my_column_index = i; + my_column_height = height; + } else { + if (my_column_index === undefined) { + sum_less += data.count; + } else { + sum_more += data.count; + } + column.style({ + 'background': beestat.style.color.bluegray.light, + 'border-bottom': '2px solid ' + beestat.style.color.bluegray.light + }); + } + chart.appendChild(column); }); - inner_container.appendChild(icon); - inner_container.appendChild(line); - inner_container.appendChild(min); - inner_container.appendChild(max); - inner_container.appendChild(label); - inner_container.appendChild(circle); - inner_container.appendChild(chart); + const label_height = 16; + const label_bottom = Math.max(20, ((chart_height - chart_padding) * my_column_height / 100)); + var label = $.createElement('div') + .innerText(formatter(this.get_value_(), this.get_precision_())) + .style({ + 'position': 'absolute', + 'bottom': label_bottom + 'px', + 'left': Math.min(85, ((my_column_index * column_width) + (column_width / 2))) + '%', + 'width': '60px', + 'height': label_height + 'px', + 'line-height': label_height + 'px', + 'margin-left': '-30px', + 'text-align': 'center', + 'font-weight': beestat.style.font_weight.bold, + 'text-shadow': '1px 1px 1px rgba(0, 0, 0, 0.5)' + }); + chart.appendChild(label); - outer_container.appendChild(inner_container); + // Min & Max + chart.appendChild( + $.createElement('div') + .style({ + 'position': 'absolute', + 'left': '4px', + 'bottom': '2px', + 'color': 'rgba(255, 255, 255, 0.5)', + 'font-size': '11px' + }) + .innerText(formatter(this.get_min_(), this.get_precision_())) + ); + chart.appendChild( + $.createElement('div') + .style({ + 'position': 'absolute', + 'right': '4px', + 'bottom': '2px', + 'color': 'rgba(255, 255, 255, 0.5)', + 'font-size': '11px' + }) + .innerText(formatter(this.get_max_(), this.get_precision_())) + ); - parent.appendChild(outer_container); + // Greater or less than % label + const percentage_label = $.createElement('div'); + + let percentage; + let symbol; + if (sum_less >= sum_more) { + symbol = '>'; + percentage = sum_less / this.get_histogram_sum_(); + } else { + symbol = '<'; + percentage = sum_more / this.get_histogram_sum_(); + } + + percentage_label.innerText( + symbol + ' ' + (percentage * 100).toFixed(0) + '% homes' + ); + td_title.appendChild(percentage_label); }; -beestat.component.metric.prototype.get_marker_position_ = function() { - return 100 * (this.get_value_() - this.get_min_()) / (this.get_max_() - this.get_min_()); -}; - -beestat.component.metric.prototype.get_histogram_max_ = function() { - var max = -Infinity; +/** + * Get the largest histogram count. + * + * @return {number} The largest histogram count. + */ +beestat.component.metric.prototype.get_histogram_mode_ = function() { + let mode = -Infinity; this.get_histogram_().forEach(function(data) { - max = Math.max(max, data.count); + mode = Math.max(mode, data.count); }); + return mode; +}; + +/** + * Get the sum of the histogram counts. + * + * @return {number} The sum of the histogram counts. + */ +beestat.component.metric.prototype.get_histogram_sum_ = function() { + let sum = 0; + this.get_histogram_().forEach(function(data) { + sum += data.count; + }); + return sum; +}; + +/** + * Get the unit string to append to the end of the value. + * + * @return {mixed} The unit string. + */ +beestat.component.metric.prototype.get_units_ = function() { + return ''; +}; + +/** + * Get the a formatter function that applies a transformation to the value. + * + * @return {mixed} A function that formats the string. + */ +beestat.component.metric.prototype.get_formatter_ = function() { + var self = this; + + return function(value) { + let return_value = value; + if (self.is_temperature_ === true) { + return_value = beestat.temperature({ + 'temperature': value, + 'delta': self.is_temperature_delta_ + }); + } + return return_value.toFixed(self.get_precision_()) + self.get_units_(); + }; +}; + +/** + * Get the minimum value of this metric (within two standard deviations). Then + * make it go to the min of that and the actual value. + * + * @return {mixed} The minimum value of this metric. + */ +beestat.component.metric.prototype.get_min_ = function() { + const metric = this.get_metric_(); + const cutoff_min = this.get_cutoff_min_(); + + // Median minus 2 * standard deviation + let min = (metric.median - (metric.standard_deviation * 2)); + + // If lower than the cutoff, place at the cutoff + if (cutoff_min !== null) { + min = Math.max(min, cutoff_min); + } + + // Unless the thermostat value is lower than the cutoff, then go there + min = Math.min(min, this.get_value_()); + + // Round down to the nearest interval + min = this.round_(min, 'floor'); + + return min; +}; + +/** + * Get the maximum value of this metric (within two standard deviations). Then + * make it go to the max of that and the actual value. + * + * @return {mixed} The maximum value of this metric. + */ +beestat.component.metric.prototype.get_max_ = function() { + const metric = this.get_metric_(); + const cutoff_max = this.get_cutoff_max_(); + + // Median plus 2 * standard deviation + let max = (metric.median + (metric.standard_deviation * 2)); + + // If higher than the cutoff, place at the cutoff + if (cutoff_max !== null) { + max = Math.min(max, cutoff_max); + } + + // Unless the thermostat value is higher than the cutoff, then go there + max = Math.max(max, this.get_value_()); + + // Round up to the nearest interval + max = this.round_(max, 'ceil'); + return max; }; + +/** + * Get max cutoff. This is used to set the chart min to max(median - 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.prototype.get_cutoff_min_ = function() { + return null; +}; + +/** + * Get max cutoff. This is used to set the chart max to min(median + 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.prototype.get_cutoff_max_ = function() { + return null; +}; + +/** + * Get the value of this metric. + * + * @return {mixed} The value of this metric. + */ +beestat.component.metric.prototype.get_value_ = function() { + const thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + if (this.child_metric_name_ !== undefined) { + return this.round_( + thermostat.profile[this.parent_metric_name_][this.child_metric_name_] + ); + } + + return this.round_(thermostat.profile[this.parent_metric_name_]); +}; + +/** + * Get the actual metric object as returned from thermostat->get_metrics(). + * + * @return {array} THe metric object. + */ +beestat.component.metric.prototype.get_metric_ = function() { + if (this.child_metric_name_ !== undefined) { + return beestat.cache.data.metrics[this.parent_metric_name_][this.child_metric_name_]; + } + + return beestat.cache.data.metrics[this.parent_metric_name_]; +}; + +/** + * Take the histogram returned from the API, fill in missing values, and + * remove anything outside the min and max. + * + * @return {array} Histogram data. + */ +beestat.component.metric.prototype.get_histogram_ = function() { + const metric = this.get_metric_(); + + const min = this.get_min_(); + const max = this.get_max_(); + + const my_value = this.get_value_(); + + var histogram = []; + for (let value = min; value <= max; value += metric.interval) { + let count = metric.histogram[value.toFixed(this.get_precision_())] || 0; + + // The API call does not include me in the histogram; add it here. + if (value.toFixed(this.get_precision_()) === my_value.toFixed(this.get_precision_())) { + count++; + } + + histogram.push({ + 'value': value, + 'count': count + }); + } + + return histogram; +}; + +/** + * Based on the interval, get the precision. + * + * @return {number} The precision. + */ +beestat.component.metric.prototype.get_precision_ = function() { + const metric = this.get_metric_(); + if (Math.floor(metric.interval) === metric.interval) { + return 0; + } + return metric.interval.toString().split('.')[1].length || 0; +}; + +/** + * Round a number to the precision that this metric supports. Useful, for + * example, because the profile is sometimes a higher precision than the + * metric uses for display purposes. + * + * @param {number} value The value to round. + * @param {string} mode The math function to use when rounding. Default round, + * can also choose floor or ceil. + * + * @return {number} The rounded value. + */ +beestat.component.metric.prototype.round_ = function(value, mode) { + const metric = this.get_metric_(); + const math_function = (mode === undefined) ? 'round' : mode; + return Math[math_function](value / metric.interval) * metric.interval; +}; diff --git a/js/component/metric/balance_point.js b/js/component/metric/balance_point.js new file mode 100644 index 0000000..50ac463 --- /dev/null +++ b/js/component/metric/balance_point.js @@ -0,0 +1,43 @@ +/** + * Balance point metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.balance_point = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.balance_point, beestat.component.metric); + +beestat.component.metric.balance_point.prototype.parent_metric_name_ = 'balance_point'; + +beestat.component.metric.balance_point.prototype.is_temperature_ = true; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.balance_point.prototype.get_units_ = function() { + return beestat.setting('temperature_unit'); +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.balance_point.prototype.get_title_ = function() { + return beestat.series['compressor_' + this.child_metric_name_].name; +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.balance_point.prototype.get_color_ = function() { + return beestat.series['compressor_' + this.child_metric_name_].color; +}; + diff --git a/js/component/metric/balance_point/heat_1.js b/js/component/metric/balance_point/heat_1.js new file mode 100644 index 0000000..3be24d4 --- /dev/null +++ b/js/component/metric/balance_point/heat_1.js @@ -0,0 +1,22 @@ +/** + * Balance Point for Heat Stage 1 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.balance_point.heat_1 = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.balance_point.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.balance_point.heat_1, beestat.component.metric.balance_point); + +beestat.component.metric.balance_point.heat_1.prototype.child_metric_name_ = 'heat_1'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.balance_point.heat_1.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/balance_point/heat_2.js b/js/component/metric/balance_point/heat_2.js new file mode 100644 index 0000000..b35ccc2 --- /dev/null +++ b/js/component/metric/balance_point/heat_2.js @@ -0,0 +1,22 @@ +/** + * Balance Point for Heat Stage 2 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.balance_point.heat_2 = function(thermostat_id) { + this.thermostat_group_id_ = thermostat_id; + + beestat.component.metric.balance_point.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.balance_point.heat_2, beestat.component.metric.balance_point); + +beestat.component.metric.balance_point.heat_2.prototype.child_metric_name_ = 'heat_2'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.balance_point.heat_2.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/balance_point/resist.js b/js/component/metric/balance_point/resist.js new file mode 100644 index 0000000..fe5199f --- /dev/null +++ b/js/component/metric/balance_point/resist.js @@ -0,0 +1,40 @@ +/** + * Balance Point for Resist + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.balance_point.resist = function(thermostat_id) { + this.thermostat_group_id_ = thermostat_id; + + beestat.component.metric.balance_point.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.balance_point.resist, beestat.component.metric.balance_point); + +beestat.component.metric.balance_point.resist.prototype.child_metric_name_ = 'resist'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.balance_point.resist.prototype.get_icon_ = function() { + return 'resistor'; +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.balance_point.resist.prototype.get_title_ = function() { + return 'Resist'; +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.balance_point.resist.prototype.get_color_ = function() { + return beestat.style.color.gray.dark; +}; diff --git a/js/component/metric/property.js b/js/component/metric/property.js new file mode 100644 index 0000000..c49c249 --- /dev/null +++ b/js/component/metric/property.js @@ -0,0 +1,32 @@ +/** + * Property metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.property = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.property, beestat.component.metric); + +beestat.component.metric.property.prototype.parent_metric_name_ = 'property'; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.property.prototype.get_title_ = function() { + return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1); +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.property.prototype.get_color_ = function() { + return beestat.style.color.purple.base; +}; + diff --git a/js/component/metric/property/age.js b/js/component/metric/property/age.js new file mode 100644 index 0000000..cea78c7 --- /dev/null +++ b/js/component/metric/property/age.js @@ -0,0 +1,50 @@ +/** + * Property age metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.property.age = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.property.age, beestat.component.metric.property); + +beestat.component.metric.property.age.prototype.child_metric_name_ = 'age'; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.property.age.prototype.get_units_ = function() { + return 'y'; +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.property.age.prototype.get_title_ = function() { + return 'Age'; +}; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.property.age.prototype.get_icon_ = function() { + return 'clock_outline'; +}; + +/** + * Get max cutoff. This is used to set the chart min to max(median - 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.property.age.prototype.get_cutoff_min_ = function() { + return 0; +}; diff --git a/js/component/metric/property/square_feet.js b/js/component/metric/property/square_feet.js new file mode 100644 index 0000000..73a40dd --- /dev/null +++ b/js/component/metric/property/square_feet.js @@ -0,0 +1,72 @@ +/** + * Property square feet metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.property.square_feet = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.property.square_feet, beestat.component.metric.property); + +beestat.component.metric.property.square_feet.prototype.child_metric_name_ = 'square_feet'; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.property.square_feet.prototype.get_units_ = function() { + return 'ft²'; +}; + +/** + * Get the a formatter function that applies a transformation to the value. + * + * @return {mixed} A function that formats the string. + */ +beestat.component.metric.property.square_feet.prototype.get_formatter_ = function() { + var self = this; + + return function(value, precision) { + return value.toLocaleString() + self.get_units_(); + }; +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.property.square_feet.prototype.get_title_ = function() { + return 'Square Feet'; +}; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.property.square_feet.prototype.get_icon_ = function() { + return 'view_quilt'; +}; + +/** + * Get max cutoff. This is used to set the chart min to max(median - 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.property.square_feet.prototype.get_cutoff_min_ = function() { + return 500; +}; + +/** + * Get the counting interval for the histogram. + * + * @return {number} The interval. + */ +beestat.component.metric.property.square_feet.prototype.get_interval_ = function() { + return 500; +}; diff --git a/js/component/metric/runtime_per_degree_day.js b/js/component/metric/runtime_per_degree_day.js new file mode 100644 index 0000000..02f0fd5 --- /dev/null +++ b/js/component/metric/runtime_per_degree_day.js @@ -0,0 +1,50 @@ +/** + * Runtime per heating degree day metric. + * + * @param {number} thermostat_id The thermostat group. + */ +beestat.component.metric.runtime_per_degree_day = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.runtime_per_degree_day, beestat.component.metric); + +beestat.component.metric.runtime_per_degree_day.prototype.parent_metric_name_ = 'runtime_per_degree_day'; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.runtime_per_degree_day.prototype.get_units_ = function() { + return 'm'; +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.runtime_per_degree_day.prototype.get_title_ = function() { + return beestat.series['compressor_' + this.child_metric_name_].name; +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.runtime_per_degree_day.prototype.get_color_ = function() { + return beestat.series['compressor_' + this.child_metric_name_].color; +}; + +/** + * Get max cutoff. This is used to set the chart min to max(median - 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.runtime_per_degree_day.prototype.get_cutoff_min_ = function() { + return 0; +}; diff --git a/js/component/metric/runtime_per_degree_day/cool_1.js b/js/component/metric/runtime_per_degree_day/cool_1.js new file mode 100644 index 0000000..fc8367b --- /dev/null +++ b/js/component/metric/runtime_per_degree_day/cool_1.js @@ -0,0 +1,22 @@ +/** + * Runtime / CDD for Cool Stage 1 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.runtime_per_degree_day.cool_1 = function(thermostat_id) { + this.thermostat_group_id_ = thermostat_id; + + beestat.component.metric.runtime_per_degree_day.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.runtime_per_degree_day.cool_1, beestat.component.metric.runtime_per_degree_day); + +beestat.component.metric.runtime_per_degree_day.cool_1.prototype.child_metric_name_ = 'cool_1'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.runtime_per_degree_day.cool_1.prototype.get_icon_ = function() { + return 'snowflake'; +}; diff --git a/js/component/metric/runtime_per_degree_day/cool_2.js b/js/component/metric/runtime_per_degree_day/cool_2.js new file mode 100644 index 0000000..8c8e298 --- /dev/null +++ b/js/component/metric/runtime_per_degree_day/cool_2.js @@ -0,0 +1,22 @@ +/** + * Runtime / CDD for Cool Stage 2 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.runtime_per_degree_day.cool_2 = function(thermostat_id) { + this.thermostat_group_id_ = thermostat_id; + + beestat.component.metric.runtime_per_degree_day.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.runtime_per_degree_day.cool_2, beestat.component.metric.runtime_per_degree_day); + +beestat.component.metric.runtime_per_degree_day.cool_2.prototype.child_metric_name_ = 'cool_2'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.runtime_per_degree_day.cool_2.prototype.get_icon_ = function() { + return 'snowflake'; +}; diff --git a/js/component/metric/runtime_per_degree_day/heat_1.js b/js/component/metric/runtime_per_degree_day/heat_1.js new file mode 100644 index 0000000..2159795 --- /dev/null +++ b/js/component/metric/runtime_per_degree_day/heat_1.js @@ -0,0 +1,22 @@ +/** + * Runtime / HDD for Heat Stage 1 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.runtime_per_degree_day.heat_1 = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.runtime_per_degree_day.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.runtime_per_degree_day.heat_1, beestat.component.metric.runtime_per_degree_day); + +beestat.component.metric.runtime_per_degree_day.heat_1.prototype.child_metric_name_ = 'heat_1'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.runtime_per_degree_day.heat_1.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/runtime_per_degree_day/heat_2.js b/js/component/metric/runtime_per_degree_day/heat_2.js new file mode 100644 index 0000000..a616089 --- /dev/null +++ b/js/component/metric/runtime_per_degree_day/heat_2.js @@ -0,0 +1,22 @@ +/** + * Runtime / HDD for Heat Stage 2 + * + * @param {number} thermostat_id The thermostat ID. + */ +beestat.component.metric.runtime_per_degree_day.heat_2 = function(thermostat_id) { + this.thermostat_group_id_ = thermostat_id; + + beestat.component.metric.runtime_per_degree_day.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.runtime_per_degree_day.heat_2, beestat.component.metric.runtime_per_degree_day); + +beestat.component.metric.runtime_per_degree_day.heat_2.prototype.child_metric_name_ = 'heat_2'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.runtime_per_degree_day.heat_2.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/runtime_per_heating_degree_day.js b/js/component/metric/runtime_per_heating_degree_day.js deleted file mode 100644 index 09635e1..0000000 --- a/js/component/metric/runtime_per_heating_degree_day.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Runtime per heating degree day metric. - * - * @param {number} thermostat_group_id The thermostat group. - */ -beestat.component.metric.runtime_per_heating_degree_day = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; - - beestat.component.metric.apply(this, arguments); -}; -beestat.extend(beestat.component.metric.runtime_per_heating_degree_day, beestat.component.metric); - -beestat.component.metric.runtime_per_heating_degree_day.prototype.rerender_on_breakpoint_ = false; - -/** - * Get the title of this metric. - * - * @return {string} The title of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_title_ = function() { - return 'Runtime / HDD'; -}; - -/** - * Get the icon of this metric. - * - * @return {string} The icon of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_icon_ = function() { - return 'fire'; -}; - -/** - * Get the color of this metric. - * - * @return {string} The color of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_color_ = function() { - return beestat.series.compressor_heat_1.color; -}; - -/** - * Get the minimum value of this metric (within two standard deviations). - * - * @return {mixed} The minimum value of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_min_ = function() { - var standard_deviation = - beestat.cache.data.metrics.runtime_per_heating_degree_day.standard_deviation; - return (beestat.cache.data.metrics.runtime_per_heating_degree_day.median - (standard_deviation * 2)).toFixed(1); -}; - -/** - * Get the maximum value of this metric (within two standard deviations). - * - * @return {mixed} The maximum value of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_max_ = function() { - var standard_deviation = - beestat.cache.data.metrics.runtime_per_heating_degree_day.standard_deviation; - return (beestat.cache.data.metrics.runtime_per_heating_degree_day.median + (standard_deviation * 2)).toFixed(1); -}; - -/** - * Get the value of this metric. - * - * @return {mixed} The value of this metric. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_value_ = function() { - var thermostat_group = beestat.cache.thermostat_group[ - this.thermostat_group_id_ - ]; - // todo: store this explicitly on the profile so it doesn't have to be calculated in JS? - return (thermostat_group.profile.runtime.heat_1 / - thermostat_group.profile.degree_days.heat).toFixed(1); -}; - -/** - * Get a histogram between the min and max values of this metric. - * - * @return {array} The histogram. - */ -beestat.component.metric.runtime_per_heating_degree_day.prototype.get_histogram_ = function() { - var histogram = []; - for (var value in beestat.cache.data.metrics.runtime_per_heating_degree_day.histogram) { - if ( - value >= this.get_min_() && - value <= this.get_max_() - ) { - var count = beestat.cache.data.metrics.runtime_per_heating_degree_day.histogram[value]; - histogram.push({ - 'value': value, - 'count': count - }); - } - } - return histogram; -}; diff --git a/js/component/metric/setback.js b/js/component/metric/setback.js new file mode 100644 index 0000000..8be0c99 --- /dev/null +++ b/js/component/metric/setback.js @@ -0,0 +1,54 @@ +/** + * Setback metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setback = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setback, beestat.component.metric); + +beestat.component.metric.setback.prototype.parent_metric_name_ = 'setback'; + +beestat.component.metric.setback.prototype.is_temperature_ = true; + +beestat.component.metric.setback.prototype.is_temperature_delta_ = true; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.setback.prototype.get_units_ = function() { + return beestat.setting('temperature_unit'); +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.setback.prototype.get_title_ = function() { + return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1); +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.setback.prototype.get_color_ = function() { + return beestat.series['compressor_' + this.child_metric_name_ + '_1'].color; +}; + +/** + * Get max cutoff. This is used to set the chart min to max(median - 2 * + * stddev, max cutoff). + * + * @return {object} The cutoff value. + */ +beestat.component.metric.setback.prototype.get_cutoff_min_ = function() { + return 0; +}; diff --git a/js/component/metric/setback/cool.js b/js/component/metric/setback/cool.js new file mode 100644 index 0000000..67b718e --- /dev/null +++ b/js/component/metric/setback/cool.js @@ -0,0 +1,22 @@ +/** + * Cool setback metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setback.cool = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.setback.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setback.cool, beestat.component.metric.setback); + +beestat.component.metric.setback.cool.prototype.child_metric_name_ = 'cool'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.setback.cool.prototype.get_icon_ = function() { + return 'snowflake'; +}; diff --git a/js/component/metric/setback/heat.js b/js/component/metric/setback/heat.js new file mode 100644 index 0000000..78ce612 --- /dev/null +++ b/js/component/metric/setback/heat.js @@ -0,0 +1,22 @@ +/** + * Heat setback metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setback.heat = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.setback.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setback.heat, beestat.component.metric.setback); + +beestat.component.metric.setback.heat.prototype.child_metric_name_ = 'heat'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.setback.heat.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/setpoint.js b/js/component/metric/setpoint.js new file mode 100644 index 0000000..e9ec191 --- /dev/null +++ b/js/component/metric/setpoint.js @@ -0,0 +1,43 @@ +/** + * Setpoint metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setpoint = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setpoint, beestat.component.metric); + +beestat.component.metric.setpoint.prototype.parent_metric_name_ = 'setpoint'; + +beestat.component.metric.setpoint.prototype.is_temperature_ = true; + +/** + * Get the units for this metric. + * + * @return {string} The units for this metric. + */ +beestat.component.metric.setpoint.prototype.get_units_ = function() { + return beestat.setting('temperature_unit'); +}; + +/** + * Get the title of this metric. + * + * @return {string} The title of this metric. + */ +beestat.component.metric.setpoint.prototype.get_title_ = function() { + return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1); +}; + +/** + * Get the color of this metric. + * + * @return {string} The color of this metric. + */ +beestat.component.metric.setpoint.prototype.get_color_ = function() { + return beestat.series['compressor_' + this.child_metric_name_ + '_1'].color; +}; + diff --git a/js/component/metric/setpoint/cool.js b/js/component/metric/setpoint/cool.js new file mode 100644 index 0000000..eede304 --- /dev/null +++ b/js/component/metric/setpoint/cool.js @@ -0,0 +1,22 @@ +/** + * Cool setpoint metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setpoint.cool = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.setpoint.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setpoint.cool, beestat.component.metric.setpoint); + +beestat.component.metric.setpoint.cool.prototype.child_metric_name_ = 'cool'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.setpoint.cool.prototype.get_icon_ = function() { + return 'snowflake'; +}; diff --git a/js/component/metric/setpoint/heat.js b/js/component/metric/setpoint/heat.js new file mode 100644 index 0000000..f3e87f0 --- /dev/null +++ b/js/component/metric/setpoint/heat.js @@ -0,0 +1,22 @@ +/** + * Heat setpoint metric. + * + * @param {number} thermostat_id The thermostat. + */ +beestat.component.metric.setpoint.heat = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; + + beestat.component.metric.setpoint.apply(this, arguments); +}; +beestat.extend(beestat.component.metric.setpoint.heat, beestat.component.metric.setpoint); + +beestat.component.metric.setpoint.heat.prototype.child_metric_name_ = 'heat'; + +/** + * Get the icon of this metric. + * + * @return {string} The icon of this metric. + */ +beestat.component.metric.setpoint.heat.prototype.get_icon_ = function() { + return 'fire'; +}; diff --git a/js/component/metric/setpoint_cool.js b/js/component/metric/setpoint_cool.js deleted file mode 100644 index 7495739..0000000 --- a/js/component/metric/setpoint_cool.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Cool setpoint metric. - * - * @param {number} thermostat_group_id The thermostat group. - */ -beestat.component.metric.setpoint_cool = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; - - beestat.component.metric.apply(this, arguments); -}; -beestat.extend(beestat.component.metric.setpoint_cool, beestat.component.metric); - -beestat.component.metric.setpoint_cool.prototype.rerender_on_breakpoint_ = false; - -/** - * Get the title of this metric. - * - * @return {string} The title of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_title_ = function() { - return 'Cool Setpoint'; -}; - -/** - * Get the icon of this metric. - * - * @return {string} The icon of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_icon_ = function() { - return 'snowflake'; -}; - -/** - * Get the color of this metric. - * - * @return {string} The color of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_color_ = function() { - return beestat.series.compressor_cool_1.color; -}; - -/** - * Get the minimum value of this metric (within two standard deviations). - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The minimum value of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_min_ = function(units) { - var standard_deviation = - beestat.cache.data.metrics.setpoint_cool.standard_deviation; - return beestat.temperature({ - 'temperature': beestat.cache.data.metrics.setpoint_cool.median - (standard_deviation * 2), - 'round': 0, - 'units': units - }); -}; - -/** - * Get the maximum value of this metric (within two standard deviations). - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The maximum value of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_max_ = function(units) { - var standard_deviation = - beestat.cache.data.metrics.setpoint_cool.standard_deviation; - return beestat.temperature({ - 'temperature': beestat.cache.data.metrics.setpoint_cool.median + (standard_deviation * 2), - 'round': 0, - 'units': units - }); -}; - -/** - * Get the value of this metric. - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The value of this metric. - */ -beestat.component.metric.setpoint_cool.prototype.get_value_ = function(units) { - var thermostat_group = beestat.cache.thermostat_group[ - this.thermostat_group_id_ - ]; - return beestat.temperature({ - 'temperature': thermostat_group.profile.setpoint.cool, - 'units': units - }); -}; - -/** - * Get a histogram between the min and max values of this metric. - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {array} The histogram. - */ -beestat.component.metric.setpoint_cool.prototype.get_histogram_ = function(units) { - var histogram = []; - for (var temperature in beestat.cache.data.metrics.setpoint_cool.histogram) { - if ( - temperature >= this.get_min_(units) && - temperature <= this.get_max_(units) - ) { - var count = beestat.cache.data.metrics.setpoint_cool.histogram[temperature]; - histogram.push({ - 'value': beestat.temperature(temperature), - 'count': count - }); - } - } - return histogram; -}; diff --git a/js/component/metric/setpoint_heat.js b/js/component/metric/setpoint_heat.js deleted file mode 100644 index 442f199..0000000 --- a/js/component/metric/setpoint_heat.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Heat setpoint metric. - * - * @param {number} thermostat_group_id The thermostat group. - */ -beestat.component.metric.setpoint_heat = function(thermostat_group_id) { - this.thermostat_group_id_ = thermostat_group_id; - - beestat.component.metric.apply(this, arguments); -}; -beestat.extend(beestat.component.metric.setpoint_heat, beestat.component.metric); - -beestat.component.metric.setpoint_heat.prototype.rerender_on_breakpoint_ = false; - -/** - * Get the title of this metric. - * - * @return {string} The title of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_title_ = function() { - return 'Heat Setpoint'; -}; - -/** - * Get the icon of this metric. - * - * @return {string} The icon of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_icon_ = function() { - return 'fire'; -}; - -/** - * Get the color of this metric. - * - * @return {string} The color of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_color_ = function() { - return beestat.series.compressor_heat_1.color; -}; - -/** - * Get the minimum value of this metric (within two standard deviations). - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The minimum value of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_min_ = function(units) { - var standard_deviation = - beestat.cache.data.metrics.setpoint_heat.standard_deviation; - return beestat.temperature({ - 'temperature': beestat.cache.data.metrics.setpoint_heat.median - (standard_deviation * 2), - 'round': 0, - 'units': units - }); -}; - -/** - * Get the maximum value of this metric (within two standard deviations). - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The maximum value of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_max_ = function(units) { - var standard_deviation = - beestat.cache.data.metrics.setpoint_heat.standard_deviation; - return beestat.temperature({ - 'temperature': beestat.cache.data.metrics.setpoint_heat.median + (standard_deviation * 2), - 'round': 0, - 'units': units - }); -}; - -/** - * Get the value of this metric. - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {mixed} The value of this metric. - */ -beestat.component.metric.setpoint_heat.prototype.get_value_ = function(units) { - var thermostat_group = beestat.cache.thermostat_group[ - this.thermostat_group_id_ - ]; - return beestat.temperature({ - 'temperature': thermostat_group.profile.setpoint.heat, - 'units': units - }); -}; - -/** - * Get a histogram between the min and max values of this metric. - * - * @param {boolean} units Whether or not to return a numerical value or a - * string with units. - * - * @return {array} The histogram. - */ -beestat.component.metric.setpoint_heat.prototype.get_histogram_ = function(units) { - var histogram = []; - for (var temperature in beestat.cache.data.metrics.setpoint_heat.histogram) { - if ( - temperature >= this.get_min_(units) && - temperature <= this.get_max_(units) - ) { - var count = beestat.cache.data.metrics.setpoint_heat.histogram[temperature]; - histogram.push({ - 'value': beestat.temperature(temperature), - 'count': count - }); - } - } - return histogram; -}; diff --git a/js/component/modal.js b/js/component/modal.js index f155481..44e1adf 100644 --- a/js/component/modal.js +++ b/js/component/modal.js @@ -59,7 +59,7 @@ beestat.component.modal.prototype.decorate_ = function() { }, { '(max-width: 900px)': { - 'max-height': 'calc(100vh - ' + (beestat.style.size.gutter * 2) + 'px)', + 'max-height': 'calc(100vh - ' + (beestat.style.size.gutter * 2) + 'px)' } } ); diff --git a/js/component/modal/change_system_type.js b/js/component/modal/change_system_type.js index 7fd3c9a..014e74b 100644 --- a/js/component/modal/change_system_type.js +++ b/js/component/modal/change_system_type.js @@ -1,7 +1,11 @@ /** * Change system type. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for. */ -beestat.component.modal.change_system_type = function() { +beestat.component.modal.change_system_type = function(thermostat_id) { + this.thermostat_id_ = thermostat_id; beestat.component.modal.apply(this, arguments); }; beestat.extend(beestat.component.modal.change_system_type, beestat.component.modal); @@ -9,12 +13,9 @@ beestat.extend(beestat.component.modal.change_system_type, beestat.component.mod 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 - ]; + var thermostat = beestat.cache.thermostat[this.thermostat_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.')); + parent.appendChild($.createElement('p').innerHTML('What type of HVAC system do you have? System types that beestat detected are indicated.')); var options = { 'heat': [ @@ -108,7 +109,7 @@ beestat.component.modal.change_system_type.prototype.get_title_ = function() { * @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 thermostat = beestat.cache.thermostat[this.thermostat_id_]; var self = this; @@ -132,26 +133,42 @@ beestat.component.modal.change_system_type.prototype.get_buttons_ = function() { .set_background_hover_color() .removeEventListener('click'); + // Delete from the cache to trigger the metrics loading screen + beestat.cache.delete('data.metrics'); + new beestat.api() .add_call( - 'thermostat_group', - 'update_system_types', + 'thermostat', + 'set_reported_system_types', { - 'thermostat_group_id': thermostat.thermostat_group_id, + 'thermostat_id': thermostat.thermostat_id, 'system_types': self.selected_types_ }, - 'update_system_types' + 'set_reported_system_types' + ) + .add_call( + 'thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'thermostat' + ) + .add_call( + 'thermostat', + 'get_metrics', + { + 'thermostat_id': self.thermostat_id_, + 'attributes': beestat.comparisons.get_attributes() + }, + 'metrics' ) - .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.comparisons.get_comparison_scores(); + beestat.cache.set('data.metrics', response.metrics); // Close the modal. self.dispose(); diff --git a/js/component/modal/change_thermostat.js b/js/component/modal/change_thermostat.js index eed39e2..6587e41 100644 --- a/js/component/modal/change_thermostat.js +++ b/js/component/modal/change_thermostat.js @@ -35,13 +35,10 @@ beestat.component.modal.change_thermostat.prototype.decorate_contents_ = functio 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 thermostat_height = container_height - (gutter * 2); var container = $.createElement('div') .style({ @@ -52,7 +49,7 @@ beestat.component.modal.change_thermostat.prototype.decorate_thermostat_ = funct 'user-select': 'none' }); - if(thermostat_id == beestat.cache.thermostat[beestat.setting('thermostat_id')].thermostat_id) { + if (thermostat_id == beestat.cache.thermostat[beestat.setting('thermostat_id')].thermostat_id) { container.style({ 'background': '#4b6584', 'color': '#fff' diff --git a/js/js.php b/js/js.php index 4827853..7f2ef15 100755 --- a/js/js.php +++ b/js/js.php @@ -36,13 +36,14 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd 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; - echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; // Component echo '' . PHP_EOL; @@ -51,6 +52,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -59,19 +61,13 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd 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; @@ -104,9 +100,24 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd 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/load.js b/js/layer/load.js index b2c1972..df506f3 100644 --- a/js/layer/load.js +++ b/js/layer/load.js @@ -66,13 +66,6 @@ beestat.layer.load.prototype.decorate_ = function(parent) { 'thermostat' ); - api.add_call( - 'thermostat_group', - 'read_id', - {}, - 'thermostat_group' - ); - api.add_call( 'sensor', 'read_id', @@ -137,7 +130,6 @@ beestat.layer.load.prototype.decorate_ = function(parent) { }); 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);