diff --git a/api/ecobee_sensor.php b/api/ecobee_sensor.php index 5b949b3..fc95b0f 100644 --- a/api/ecobee_sensor.php +++ b/api/ecobee_sensor.php @@ -176,6 +176,8 @@ class ecobee_sensor extends cora\crud { $attributes['name'] = $api_sensor['name']; $attributes['type'] = $api_sensor['type']; $attributes['in_use'] = $api_sensor['inUse']; + $attributes['identifier'] = $api_sensor['id']; + $attributes['capability'] = $api_sensor['capability']; $attributes['inactive'] = 0; $attributes['temperature'] = null; diff --git a/api/runtime.php b/api/runtime.php new file mode 100644 index 0000000..e0fd94b --- /dev/null +++ b/api/runtime.php @@ -0,0 +1,843 @@ + [ + 'sync', + 'download' + ], + 'public' => [] + ]; + + public static $cache = [ + 'sync' => 900 // 15 Minutes + ]; + + /** + * The user_id column is not present on this table to reduce data overhead. + * No public reads are performed on this table so as long as thermostat_id + * is always used on reads in this class then this is fine. + */ + public static $user_locked = false; + + /** + * Main function for syncing thermostat data. Looks at the current state of + * things and decides which direction (forwards or backwards) makes the most + * sense. + * + * @param int $thermostat_id Optional thermostat_id to sync. If not set will + * sync all thermostats attached to this user. + */ + public function sync($thermostat_id = null) { + // Skip this for the demo + if($this->setting->is_demo() === true) { + return; + } + + set_time_limit(0); + + if($thermostat_id === null) { + $thermostat_ids = array_keys( + $this->api( + 'thermostat', + 'read_id', + [ + 'attributes' => [ + 'inactive' => 0 + ] + ] + ) + ); + } else { + $this->user_lock($thermostat_id); + $thermostat_ids = [$thermostat_id]; + } + + foreach($thermostat_ids as $thermostat_id) { + // Get a lock to ensure that this is not invoked more than once at a time + // per thermostat. + $lock_name = 'runtime->sync(' . $thermostat_id . ')'; + $this->database->get_lock($lock_name); + + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + + if( + $thermostat['sync_begin'] === $thermostat['first_connected'] || + ( + $thermostat['sync_begin'] !== null && + strtotime($thermostat['sync_begin']) <= strtotime('-1 year') + ) + ) { + $this->sync_forwards($thermostat_id); + } else { + $this->sync_backwards($thermostat_id); + } + + // If only syncing one thermostat this will delay the sync of the other + // thermostat. Not a huge deal, just FYI. + $this->api( + 'user', + 'update_sync_status', + [ + 'key' => 'runtime' + ] + ); + + $this->database->release_lock($lock_name); + } + } + + /** + * Sync backwards. When running for the first time it will sync from now all + * the way back to the first connected date. If it is called again it will + * check to see if a full backwards sync has already completed. If it has, + * it will throw an exception. If not, it will resume the backwards sync. + * + * @param int $thermostat_id + */ + private function sync_backwards($thermostat_id) { + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + + if($thermostat['sync_begin'] === $thermostat['first_connected']) { + throw new \Exception('Full sync already performed; must call sync_forwards() now.', 10200); + } + + if($thermostat['sync_begin'] === null) { + // Sync from when the thermostat was first connected until now. + $sync_begin = strtotime($thermostat['first_connected']); + $sync_end = time(); + } + else { + // Sync from when the thermostat was first connected until sync_end. + $sync_begin = strtotime($thermostat['first_connected']); + $sync_end = strtotime($thermostat['sync_begin']); + } + + // Only sync up to the past year of data. Outside of this there won't even + // be a partition for the data to go into. + $sync_begin = max(strtotime('-1 year'), $sync_begin); + + $chunk_begin = $sync_end; + $chunk_end = $sync_end; + + /** + * Loop over the dates and do the actual sync. Each chunk is wrapped in a + * transaction for a little bit of protection against exceptions + * introducing bad data and causing the whole sync to fail. Commit any + * open transactions first though. + */ + $this->database->commit_transaction(); + do { + $this->database->start_transaction(); + + $chunk_begin = strtotime('-1 week', $chunk_end); + $chunk_begin = max($chunk_begin, $sync_begin); + + $this->sync_($thermostat['thermostat_id'], $chunk_begin, $chunk_end); + + // Update the thermostat with the current sync range + $this->api( + 'thermostat', + 'update', + [ + 'attributes' => [ + 'thermostat_id' => $thermostat['thermostat_id'], + 'sync_begin' => date('Y-m-d H:i:s', $chunk_begin), + 'sync_end' => date( + 'Y-m-d H:i:s', + max( + $sync_end, + strtotime($thermostat['sync_end']) + ) + ) + ] + ] + ); + + // Populate on the fly. + $this->api( + 'runtime_thermostat_summary', + 'populate', + $thermostat_id + ); + + // Because I am doing day-level syncing this will end up fetching an + // overlapping day of data every time. But if I properly switch this to + // interval-level syncing this should be correct or at the very least + // return a minimal one extra row of data. + $chunk_end = $chunk_begin; + + $this->database->commit_transaction(); + } while ($chunk_begin > $sync_begin); + } + + /** + * Sync forwards from thermostat.sync_end until now. This should be used for + * all syncs except the first one. + * + * @param int $thermostat_id + */ + private function sync_forwards($thermostat_id) { + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + + // Sync from the last sync time until now. Go a couple hours back in time to + // cover that 1 hour delay. + $sync_begin = strtotime($thermostat['sync_end'] . ' -2 hours'); + $sync_end = time(); + + $chunk_begin = $sync_begin; + $chunk_end = $sync_begin; + + // Loop over the dates and do the actual sync. Each chunk is wrapped in a + // transaction for a little bit of protection against exceptions introducing + // bad data and causing the whole sync to fail. + do { + $this->database->start_transaction(); + + $chunk_end = strtotime('+1 week', $chunk_begin); + $chunk_end = min($chunk_end, $sync_end); + + $this->sync_($thermostat['thermostat_id'], $chunk_begin, $chunk_end); + + // Update the thermostat with the current sync range + $this->api( + 'thermostat', + 'update', + [ + 'attributes' => [ + 'thermostat_id' => $thermostat['thermostat_id'], + 'sync_end' => date('Y-m-d H:i:s', $chunk_end) + ] + ] + ); + + $chunk_begin = strtotime('+1 day', $chunk_end); + + $this->database->commit_transaction(); + } while ($chunk_end < $sync_end); + + // Populate at the end of a full sync forwards. + $this->api( + 'runtime_thermostat_summary', + 'populate', + $thermostat_id + ); + } + + /** + * Get the runtime report data for a specified thermostat. Originally this + * was basically a 1:1 import from ecobee. But due to the size of the data + * this now does some fancy logic to merge certain columns down and alter + * their data types just a bit. + * + * @param int $thermostat_id + * @param int $begin + * @param int $end + */ + private function sync_($thermostat_id, $begin, $end) { + $this->user_lock($thermostat_id); + + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + $ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $thermostat['ecobee_thermostat_id']); + + /** + * Round begin/end down to the next 5 minutes. This keep things tidy. + * Without this, I would query for rows between 10:35:16, for example, and + * not get the row at 10:35:00. But the interval function would return it + * and then duplicate databse entry. + */ + $begin = floor($begin / 300) * 300; + $end = floor($end / 300) * 300; + + $begin_interval = $this->get_interval($begin); + $end_interval = $this->get_interval($end); + + $begin_date = date('Y-m-d', $begin); + $end_date = date('Y-m-d', $end); + + $response = $this->api( + 'ecobee', + 'ecobee_api', + [ + 'method' => 'GET', + 'endpoint' => 'runtimeReport', + 'arguments' => [ + 'body' => json_encode([ + 'selection' => [ + 'selectionType' => 'thermostats', + 'selectionMatch' => $ecobee_thermostat['identifier'] + ], + 'startDate' => $begin_date, + 'endDate' => $end_date, + 'startInterval' => $begin_interval, + 'endInterval' => $end_interval, + 'columns' => implode( + ',', + [ + 'compCool1', // compressor_1 + 'compCool2', // compressor_2 + 'compHeat1', // compressor_1 + 'compHeat2', // compressor_2 + 'auxHeat1', // auxiliary_heat_1 + 'auxHeat2', // auxiliary_heat_2 + 'fan', // fan + 'humidifier', // accessory + 'dehumidifier', // accessory + 'ventilator', // accessory + 'economizer', // accessory + 'hvacMode', // system_mode + 'zoneAveTemp', // indoor_temperature + 'zoneHumidity', // indoor_humidity + 'outdoorTemp', // outdoor_temperature + 'outdoorHumidity', // outdoor_humidity + 'zoneCalendarEvent', // event_runtime_thermostat_text_id + 'zoneClimate', // climate_runtime_thermostat_text_id + 'zoneCoolTemp', // setpoint_cool + 'zoneHeatTemp' // setpoint_heat + ] + ), + 'includeSensors' => true + ]) + ] + ] + ); + + + $this->sync_runtime_thermostat($thermostat, $response); + // $this->sync_runtime_sensor($thermostat, $response); + } + + private function sync_runtime_thermostat($thermostat, $response) { + /** + * Read any existing rows from the database so we know if this is an + * insert or an update. Note that even though I have $begin and $end + * already defined, I always look in the database according to what ecobee + * actually returned just in case the returned data goes outside of what I + * requested for some reason. + */ + $ecobee_columns = array_merge(['date', 'time'], explode(',', $response['columns'])); + $columns_begin = $this->get_columns( + $response['reportList'][0]['rowList'][0], + $ecobee_columns + ); + $columns_end = $this->get_columns( + $response['reportList'][0]['rowList'][count($response['reportList'][0]['rowList']) - 1], + $ecobee_columns + ); + + $existing_rows = $this->database->read( + 'runtime_thermostat', + [ + 'thermostat_id' => $thermostat['thermostat_id'], + 'timestamp' => [ + 'value' => [ + $this->get_utc_datetime( + date( + 'Y-m-d H:i:s', + strtotime($columns_begin['date'] . ' ' . $columns_begin['time'] . ' -1 hour') + ), + $thermostat['time_zone'] + ), + $this->get_utc_datetime( + date( + 'Y-m-d H:i:s', + strtotime($columns_end['date'] . ' ' . $columns_end['time'] . ' +1 hour') + ), + $thermostat['time_zone'] + ) + ], + 'operator' => 'between' + ] + ] + ); + + $existing_timestamps = []; + foreach($existing_rows as $existing_row) { + $existing_timestamps[$existing_row['timestamp']] = $existing_row['runtime_thermostat_id']; + } + + // Loop over the ecobee data + foreach ($response['reportList'][0]['rowList'] as $row) { + $columns = $this->get_columns( + $row, + $ecobee_columns + ); + + /** + * If any of these values are null just throw the whole row away. This + * should very rarely happen as ecobee reports 0 values. The driver of + * this is the summary table...trying to aggregate sums for these values + * is easy, but in order to accurately represent those sums I need to + * know how many rows were included. I would have to store one count per + * sum in the summary table to manage that...or just not store the data + * to begin with. + * + * Also threw in null checks on a bunch of other fields just to simplify + * the code later on. This happens so rarely that throwing away a whole + * row for a null value shouldn't have any noticeable negative effect. + * + * Note: Don't ignore zoneHeatTemp or zoneCoolTemp. Sometimes those are + * legit null as the thermostat may be for heat/cool only (see #160). + */ + if( + $columns['HVACmode'] === null || + $columns['zoneAveTemp'] === null || + $columns['zoneHumidity'] === null || + $columns['outdoorTemp'] === null || + $columns['outdoorHumidity'] === null || + $columns['compHeat1'] === null || + $columns['compHeat2'] === null || + $columns['compCool1'] === null || + $columns['compCool2'] === null || + $columns['auxHeat1'] === null || + $columns['auxHeat2'] === null || + $columns['fan'] === null || + $columns['humidifier'] === null || + $columns['dehumidifier'] === null || + $columns['ventilator'] === null || + $columns['economizer'] === null + ) { + continue; + } + + // Date and time are first two columns of the returned data. It is + // returned in thermostat time, so convert it to UTC first. + $timestamp = $this->get_utc_datetime( + $columns['date'] . ' ' . $columns['time'], + $thermostat['time_zone'] + ); + + $data = []; + + $data['thermostat_id'] = $thermostat['thermostat_id']; + $data['timestamp'] = $timestamp; + + if ($columns['compCool1'] > 0 || $columns['compCool2'] > 0) { + $data['compressor_mode'] = 'cool'; + $data['compressor_1'] = $columns['compCool1'] - $columns['compCool2']; + $data['compressor_2'] = $columns['compCool2']; + } else if ($columns['compHeat1'] > 0 || $columns['compHeat2'] > 0) { + $data['compressor_mode'] = 'heat'; + $data['compressor_1'] = $columns['compHeat1'] - $columns['compHeat2']; + $data['compressor_2'] = $columns['compHeat2']; + } else { + $data['compressor_mode'] = 'off'; + $data['compressor_1'] = 0; + $data['compressor_2'] = 0; + } + + $data['auxiliary_heat_1'] = $columns['auxHeat1'] - $columns['auxHeat2']; + $data['auxiliary_heat_2'] = $columns['auxHeat2']; + + if($columns['humidifier'] > 0) { + $data['accessory_type'] = 'humidifier'; + $data['accessory'] = $columns['humidifier']; + } else if($columns['dehumidifier'] > 0) { + $data['accessory_type'] = 'dehumidifier'; + $data['accessory'] = $columns['dehumidifier']; + } else if($columns['ventilator'] > 0) { + $data['accessory_type'] = 'ventilator'; + $data['accessory'] = $columns['ventilator']; + } else if($columns['economizer'] > 0) { + $data['accessory_type'] = 'economizer'; + $data['accessory'] = $columns['economizer']; + } else { + $data['accessory_type'] = 'off'; + $data['accessory'] = 0; + } + + // Ecobee does not report fan usage when it does not control the fan, so + // this will mark the fan as running when certain equipment is on. + $data['fan'] = max( + $columns['fan'], + $data['compressor_1'], + $data['compressor_2'], + $data['auxiliary_heat_1'], + $data['auxiliary_heat_2'], + $data['accessory'] + ); + + $system_modes = [ + 'auto' => 'auto', + 'cool' => 'cool', + 'heat' => 'heat', + 'auxHeatOnly' => 'auxiliary_heat', + 'off' => 'off' + ]; + $data['system_mode'] = $system_modes[$columns['HVACmode']]; + + $data['indoor_temperature'] = $columns['zoneAveTemp'] * 10; + $data['indoor_humidity'] = round($columns['zoneHumidity']); + + $data['outdoor_temperature'] = $columns['outdoorTemp'] * 10; + $data['outdoor_humidity'] = round($columns['outdoorHumidity']); + + $data['event_runtime_thermostat_text_id'] = $this->api( + 'runtime_thermostat_text', + 'get_create', + $columns['zoneCalendarEvent'] + )['runtime_thermostat_text_id']; + $data['climate_runtime_thermostat_text_id'] = $this->api( + 'runtime_thermostat_text', + 'get_create', + $columns['zoneClimate'] + )['runtime_thermostat_text_id']; + + $data['setpoint_cool'] = $columns['zoneCoolTemp'] * 10; + $data['setpoint_heat'] = $columns['zoneHeatTemp'] * 10; + + // Create or update the database + if(isset($existing_timestamps[$timestamp]) === true) { + $data['runtime_thermostat_id'] = $existing_timestamps[$timestamp]; + $this->database->update('runtime_thermostat', $data, 'id'); + } + else { + $existing_timestamps[$timestamp] = $this->database->create('runtime_thermostat', $data, 'id'); + } + } + } + + private function sync_runtime_sensor($thermostat, $response) { + /** + * Read any existing rows from the database so we know if this is an + * insert or an update. Note that even though I have $begin and $end + * already defined, I always look in the database according to what ecobee + * actually returned just in case the returned data goes outside of what I + * requested for some reason. + */ + $ecobee_columns = $response['sensorList'][0]['columns']; + $columns_begin = $this->get_columns( + $response['sensorList'][0]['data'][0], + $response['sensorList'][0]['columns'] + ); + $columns_end = $this->get_columns( + $response['sensorList'][0]['data'][count($response['sensorList'][0]['data']) - 1], + $ecobee_columns + ); + + // Get a list of sensors + $sensors = $this->api( + 'sensor', + 'read', + [ + 'attributes' => [ + 'thermostat_id' => $thermostat['thermostat_id'] + ] + ] + ); + + // Get a list of sensors indexed by code + $sensors_by_identifier = []; + foreach($sensors as $sensor) { + $sensors_by_identifier[$sensor['identifier']] = $sensor; + } + + $existing_rows = $this->database->read( + 'runtime_sensor', + [ + 'sensor_id' => array_column($sensors, 'sensor_id'), + 'timestamp' => [ + 'value' => [ + $this->get_utc_datetime( + date( + 'Y-m-d H:i:s', + strtotime($columns_begin['date'] . ' ' . $columns_begin['time'] . ' -1 hour') + ), + $thermostat['time_zone'] + ), + $this->get_utc_datetime( + date( + 'Y-m-d H:i:s', + strtotime($columns_end['date'] . ' ' . $columns_end['time'] . ' +1 hour') + ), + $thermostat['time_zone'] + ) + ], + 'operator' => 'between' + ] + ] + ); + + $existing_timestamps = []; + foreach($existing_rows as $existing_row) { + if (isset($existing_timestamps[$existing_row['sensor_id']]) === false) { + $existing_timestamps[$existing_row['sensor_id']] = []; + } + $existing_timestamps[$existing_row['sensor_id']][$existing_row['timestamp']] = $existing_row['runtime_sensor_id']; + } + + // Loop over the ecobee data. Ecobee if you're reading this please don't + // format your identifiers like this in the future. + foreach ($response['sensorList'][0]['data'] as $row) { + $columns = $this->get_columns( + $row, + $ecobee_columns + ); + $datas = []; + + foreach ($columns as $key => $value) { + if ($key === 'date' || $key === 'time') { + continue; + } + + $sensor_identifier = substr($key, 0, strrpos($key, ':')); + $capability_identifier = substr($key, strrpos($key, ':') + 1); + + $sensor = $sensors_by_identifier[$sensor_identifier]; + $sensor_id = $sensors_by_identifier[$sensor_identifier]['sensor_id']; + + if (isset($datas[$sensor['sensor_id']]) === false) { + $datas[$sensor['sensor_id']] = [ + 'sensor_id' => $sensor['sensor_id'], + 'timestamp' => $this->get_utc_datetime( + $columns['date'] . ' ' . $columns['time'], + $thermostat['time_zone'] + ) + ]; + } + + foreach($sensor['capability'] as $capability) { + if( + $capability['id'] == $capability_identifier && + in_array($capability['type'], ['temperature', 'occupancy']) === true + ) { + $datas[$sensor['sensor_id']][$capability['type']] = ($capability['type'] === 'temperature') ? ($value * 10) : $value; + } + } + } + + // Create or update the database + foreach ($datas as $data) { + if(isset($existing_timestamps[$data['sensor_id']][$data['timestamp']]) === true) { + $data['runtime_sensor_id'] = $existing_timestamps[$data['sensor_id']][$data['timestamp']]; + $this->database->update('runtime_sensor', $data, 'id'); + } + else { + $existing_timestamps[$data['sensor_id']][$data['timestamp']] = $this->database->create('runtime_sensor', $data, 'id'); + } + } + } + } + + /** + * Get the ecobee "interval" value from a timestamp. + * + * @param string $timestamp The timestamp. + * + * @return int The interval. + */ + private function get_interval($timestamp) { + $hours = date('G', $timestamp); + $minutes = date('i', $timestamp); + + return ($hours * 12) + floor($minutes / 5); + } + + /** + * Convert a string CSV row to cleaned up array of columns. + * + * @param string $row The CSV row string. + * @param array $columns The column headers. + * + * @return array + */ + private function get_columns($row, $columns) { + $return = []; + + // Explode and remove trailing comma if present. + if (substr($row, -1, 1) === ',') { + $row = substr($row, 0, -1); + } + $data = explode(',', $row); + + for ($i = 0; $i < count($data); $i++) { + $data[$i] = trim($data[$i]); + $return[$columns[$i]] = $data[$i] === '' ? null : $data[$i]; + } + + return $return; + } + + /** + * Convert a local datetime string to a UTC datetime string. + * + * @param string $local_datetime Local datetime string. + * @param string $local_time_zone The local time zone to convert from. + * + * @return string The UTC datetime string. + */ + private function get_utc_datetime($local_datetime, $local_time_zone) { + $local_time_zone = new DateTimeZone($local_time_zone); + $utc_time_zone = new DateTimeZone('UTC'); + $date_time = new DateTime($local_datetime, $local_time_zone); + $date_time->setTimezone($utc_time_zone); + + return $date_time->format('Y-m-d H:i:s'); + } + + /** + * Convert a UTC datetime string to a UTC datetime string. + * + * @param string $utc_datetime Local datetime string. + * @param string $local_time_zone The local time zone to convert from. + * + * @return string The UTC datetime string. + */ + private function get_local_datetime($utc_datetime, $local_time_zone) { + $local_time_zone = new DateTimeZone($local_time_zone); + $utc_time_zone = new DateTimeZone('UTC'); + $date_time = new DateTime($utc_datetime, $utc_time_zone); + $date_time->setTimezone($local_time_zone); + + return $date_time->format('Y-m-d H:i:s'); + } + + /** + * Download all data that exists for a specific thermostat. + * + * @param int $thermostat_id + * @param string $download_begin Optional; the date to begin the download. + * @param string $download_end Optional; the date to end the download. + */ + public function download($thermostat_id, $download_begin = null, $download_end = null) { + set_time_limit(120); + + $this->user_lock($thermostat_id); + + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + $ecobee_thermostat = $this->api( + 'ecobee_thermostat', + 'get', + $thermostat['ecobee_thermostat_id'] + ); + + if($download_begin === null) { + $download_begin = strtotime($thermostat['first_connected']); + } else { + $download_begin = strtotime($download_begin); + } + + if($download_end === null) { + $download_end = time(); + } else { + $download_end = strtotime($download_end); + } + + $chunk_begin = $download_begin; + $chunk_end = $download_begin; + + $bytes = 0; + + $output = fopen('php://output', 'w'); + $needs_header = true; + do { + $chunk_end = strtotime('+1 week', $chunk_begin); + $chunk_end = min($chunk_end, $download_end); + + $runtime_thermostats = $this->database->read( + 'runtime_thermostat', + [ + 'thermostat_id' => $thermostat_id, + 'timestamp' => [ + 'value' => [date('Y-m-d H:i:s', $chunk_begin), date('Y-m-d H:i:s', $chunk_end)] , + 'operator' => 'between' + ] + ], + [], + 'timestamp' // order by + ); + + // Get the appropriate runtime_thermostat_texts. + $runtime_thermostat_text_ids = array_unique(array_merge( + array_column($runtime_thermostats, 'event_runtime_thermostat_text_id'), + array_column($runtime_thermostats, 'climate_runtime_thermostat_text_id') + )); + $runtime_thermostat_texts = $this->api( + 'runtime_thermostat_text', + 'read_id', + [ + 'attributes' => [ + 'runtime_thermostat_text_id' => $runtime_thermostat_text_ids + ] + ] + ); + + + if ($needs_header === true && count($runtime_thermostats) > 0) { + $headers = array_keys($runtime_thermostats[0]); + + // Remove the IDs and rename two columns. + unset($headers[array_search('runtime_thermostat_id', $headers)]); + unset($headers[array_search('thermostat_id', $headers)]); + $headers[array_search('event_runtime_thermostat_text_id', $headers)] = 'event'; + $headers[array_search('climate_runtime_thermostat_text_id', $headers)] = 'climate'; + + $bytes += fputcsv($output, $headers); + $needs_header = false; + } + + foreach($runtime_thermostats as $runtime_thermostat) { + unset($runtime_thermostat['runtime_thermostat_id']); + unset($runtime_thermostat['thermostat_id']); + + $runtime_thermostat['timestamp'] = $this->get_local_datetime( + $runtime_thermostat['timestamp'], + $thermostat['time_zone'] + ); + + // Return temperatures in a human-readable format. + foreach(['indoor_temperature', 'outdoor_temperature', 'setpoint_heat', 'setpoint_cool'] as $key) { + if($runtime_thermostat[$key] !== null) { + $runtime_thermostat[$key] /= 10; + } + } + + // Replace event and climate with their string values. + if ($runtime_thermostat['event_runtime_thermostat_text_id'] !== null) { + $runtime_thermostat['event_runtime_thermostat_text_id'] = $runtime_thermostat_texts[$runtime_thermostat['event_runtime_thermostat_text_id']]['value']; + } + + if ($runtime_thermostat['climate_runtime_thermostat_text_id'] !== null) { + $runtime_thermostat['climate_runtime_thermostat_text_id'] = $runtime_thermostat_texts[$runtime_thermostat['climate_runtime_thermostat_text_id']]['value']; + } + + $bytes += fputcsv($output, $runtime_thermostat); + } + + $chunk_begin = strtotime('+1 day', $chunk_end); + } while ($chunk_end < $download_end); + fclose($output); + + $this->cora->set_headers([ + 'Content-Type' => 'text/csv', + 'Content-Length' => $bytes, + 'Content-Disposition' => 'attachment; filename="Beestat Export - ' . $ecobee_thermostat['identifier'] . '.csv"', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ], true); + } + + /** + * Since this table does not have a user_id column, security must be handled + * manually. Call this with a thermostat_id to verify that the current user + * has access to the requested thermostat. + * + * @param int $thermostat_id + * + * @throws \Exception If the current user doesn't have access to the + * requested thermostat. + */ + private function user_lock($thermostat_id) { + $thermostats = $this->api('thermostat', 'read_id'); + if (isset($thermostats[$thermostat_id]) === false) { + throw new \Exception('Invalid thermostat_id.', 10203); + } + } + +} diff --git a/api/runtime_sensor.php b/api/runtime_sensor.php new file mode 100644 index 0000000..841fa7e --- /dev/null +++ b/api/runtime_sensor.php @@ -0,0 +1,138 @@ + [ + 'read' + ], + 'public' => [] + ]; + + public static $cache = [ + 'read' => 900 // 15 Minutes + ]; + + /** + * The user_id column is not present on this table to reduce data overhead. + * No public reads are performed on this table so as long as thermostat_id + * is always used on reads in this class then this is fine. + */ + public static $user_locked = false; + + /** + * Read data from the runtime_sensor table. Basically just a crud read + * but has custom security for thermostat_id since user_id is not on the + * table. + * + * @param array $attributes Timestamps can be specified in any format. If no + * time zone information is sent, UTC is assumed. + * @param array $columns + * + * @return array + */ + public function read($attributes = [], $columns = []) { + $this->user_lock($attributes['sensor_id']); + + // Check for exceptions. + if (isset($attributes['sensor_id']) === false) { + throw new \Exception('Missing required attribute: sensor_id.', 10401); + } + + if (isset($attributes['timestamp']) === false) { + throw new \Exception('Missing required attribute: timestamp.', 10402); + } + + if ( + is_array($attributes['timestamp']) === true && + in_array($attributes['timestamp']['operator'], ['>', '>=', '<', '<=']) === true && + is_array($attributes['timestamp']['value']) === true + ) { + if(count($attributes['timestamp']['value']) === 1) { + $attributes['timestamp']['value'] = $attributes['timestamp']['value'][0]; + } else { + throw new \Exception('Must only specify one timestamp value unless using the "between" operator.', 10404); + } + } + + $sensor = $this->api('sensor', 'get', $attributes['sensor_id']); + $thermostat = $this->api('thermostat', 'get', $sensor['thermostat_id']); + $max_range = 2678000; // 31 days + if ( + ( + is_array($attributes['timestamp']) === true && + $attributes['timestamp']['operator'] === 'between' && + abs(strtotime($attributes['timestamp']['value'][0]) - strtotime($attributes['timestamp']['value'][1])) > $max_range + ) || + ( + is_array($attributes['timestamp']) === true && + in_array($attributes['timestamp']['operator'], ['>', '>=']) === true && + time() - strtotime($attributes['timestamp']['value']) > $max_range + ) || + ( + is_array($attributes['timestamp']) === true && + in_array($attributes['timestamp']['operator'], ['<', '<=']) === true && + strtotime($attributes['timestamp']['value']) - min(strtotime($thermostat['first_connected']), strtotime($thermostat['sync_begin'])) > $max_range + ) + ) { + throw new \Exception('Max range is 31 days. ' . (time() - strtotime($attributes['timestamp']['value'])), 10405); + } + + // Accept timestamps in roughly any format; always convert back to something nice and in UTC + if (is_array($attributes['timestamp']['value']) === true) { + $attributes['timestamp']['value'][0] = date('c', strtotime($attributes['timestamp']['value'][0])); + $attributes['timestamp']['value'][1] = date('c', strtotime($attributes['timestamp']['value'][1])); + } else { + $attributes['timestamp']['value'] = date('c', strtotime($attributes['timestamp']['value'])); + } + + // Read the data. + $runtime_sensors = $this->database->read( + 'runtime_sensor', + [ + 'timestamp' => $attributes['timestamp'], + 'sensor_id' => $attributes['sensor_id'] + ], + [], + 'timestamp' // order by + ); + + // Clean up the data just a bit. + foreach ($runtime_sensors as &$runtime_sensor) { + $runtime_sensor['timestamp'] = date( + 'c', + strtotime($runtime_sensor['timestamp']) + ); + + if ($runtime_sensor['temperature'] !== null) { + $runtime_sensor['temperature'] /= 10; + } + } + + return $runtime_sensors; + } + + + /** + * Since this table does not have a user_id column, security must be handled + * manually. Call this with a sensor_id to verify that the current user has + * access to the requested sensor_id. + * + * @param int $sensor_id + * + * @throws \Exception If the current user doesn't have access to the + * requested sensor. + */ + private function user_lock($sensor_id) { + $sensors = $this->api('sensor', 'read_id'); + if (isset($sensors[$sensor_id]) === false) { + throw new \Exception('Invalid sensor_id.', 10403); + } + } + +} diff --git a/api/runtime_thermostat.php b/api/runtime_thermostat.php index 97e9f92..3287f49 100755 --- a/api/runtime_thermostat.php +++ b/api/runtime_thermostat.php @@ -9,16 +9,13 @@ class runtime_thermostat extends cora\crud { public static $exposed = [ 'private' => [ - 'read', - 'sync', - 'download' + 'read' ], 'public' => [] ]; public static $cache = [ - 'sync' => 900, // 15 Minutes - 'read' => 900, // 15 Minutes + 'read' => 900 // 15 Minutes ]; /** @@ -28,543 +25,6 @@ class runtime_thermostat extends cora\crud { */ public static $user_locked = false; - /** - * List of columns that are synced from ecobee. - */ - private static $ecobee_columns = [ - 'compCool1', // compressor_1 - 'compCool2', // compressor_2 - 'compHeat1', // compressor_1 - 'compHeat2', // compressor_2 - 'auxHeat1', // auxiliary_heat_1 - 'auxHeat2', // auxiliary_heat_2 - 'fan', // fan - 'humidifier', // accessory - 'dehumidifier', // accessory - 'ventilator', // accessory - 'economizer', // accessory - 'hvacMode', // system_mode - 'zoneAveTemp', // indoor_temperature - 'zoneHumidity', // indoor_humidity - 'outdoorTemp', // outdoor_temperature - 'outdoorHumidity', // outdoor_humidity - 'zoneCalendarEvent', // event_runtime_thermostat_text_id - 'zoneClimate', // climate_runtime_thermostat_text_id - 'zoneCoolTemp', // setpoint_cool - 'zoneHeatTemp' // setpoint_heat - ]; - - /** - * Main function for syncing thermostat data. Looks at the current state of - * things and decides which direction (forwards or backwards) makes the most - * sense. - * - * @param int $thermostat_id Optional thermostat_id to sync. If not set will - * sync all thermostats attached to this user. - */ - public function sync($thermostat_id = null) { - // Skip this for the demo - if($this->setting->is_demo() === true) { - return; - } - - set_time_limit(0); - - if($thermostat_id === null) { - $thermostat_ids = array_keys( - $this->api( - 'thermostat', - 'read_id', - [ - 'attributes' => [ - 'inactive' => 0 - ] - ] - ) - ); - } else { - $this->user_lock($thermostat_id); - $thermostat_ids = [$thermostat_id]; - } - - foreach($thermostat_ids as $thermostat_id) { - // Get a lock to ensure that this is not invoked more than once at a time - // per thermostat. - $lock_name = 'runtime->sync(' . $thermostat_id . ')'; - $this->database->get_lock($lock_name); - - $thermostat = $this->api('thermostat', 'get', $thermostat_id); - - if( - $thermostat['sync_begin'] === $thermostat['first_connected'] || - ( - $thermostat['sync_begin'] !== null && - strtotime($thermostat['sync_begin']) <= strtotime('-1 year') - ) - ) { - $this->sync_forwards($thermostat_id); - } else { - $this->sync_backwards($thermostat_id); - } - - // If only syncing one thermostat this will delay the sync of the other - // thermostat. Not a huge deal, just FYI. - $this->api( - 'user', - 'update_sync_status', - [ - 'key' => 'runtime' - ] - ); - - $this->database->release_lock($lock_name); - } - } - - /** - * Sync backwards. When running for the first time it will sync from now all - * the way back to the first connected date. If it is called again it will - * check to see if a full backwards sync has already completed. If it has, - * it will throw an exception. If not, it will resume the backwards sync. - * - * @param int $thermostat_id - */ - private function sync_backwards($thermostat_id) { - $thermostat = $this->api('thermostat', 'get', $thermostat_id); - - if($thermostat['sync_begin'] === $thermostat['first_connected']) { - throw new \Exception('Full sync already performed; must call sync_forwards() now.', 10200); - } - - if($thermostat['sync_begin'] === null) { - // Sync from when the thermostat was first connected until now. - $sync_begin = strtotime($thermostat['first_connected']); - $sync_end = time(); - } - else { - // Sync from when the thermostat was first connected until sync_end. - $sync_begin = strtotime($thermostat['first_connected']); - $sync_end = strtotime($thermostat['sync_begin']); - } - - // Only sync up to the past year of data. Outside of this there won't even - // be a partition for the data to go into. - $sync_begin = max(strtotime('-1 year'), $sync_begin); - - $chunk_begin = $sync_end; - $chunk_end = $sync_end; - - /** - * Loop over the dates and do the actual sync. Each chunk is wrapped in a - * transaction for a little bit of protection against exceptions - * introducing bad data and causing the whole sync to fail. Commit any - * open transactions first though. - */ - $this->database->commit_transaction(); - do { - $this->database->start_transaction(); - - $chunk_begin = strtotime('-1 week', $chunk_end); - $chunk_begin = max($chunk_begin, $sync_begin); - - $this->sync_($thermostat['thermostat_id'], $chunk_begin, $chunk_end); - - // Update the thermostat with the current sync range - $this->api( - 'thermostat', - 'update', - [ - 'attributes' => [ - 'thermostat_id' => $thermostat['thermostat_id'], - 'sync_begin' => date('Y-m-d H:i:s', $chunk_begin), - 'sync_end' => date( - 'Y-m-d H:i:s', - max( - $sync_end, - strtotime($thermostat['sync_end']) - ) - ) - ] - ] - ); - - // Populate on the fly. - $this->api( - 'runtime_thermostat_summary', - 'populate', - $thermostat_id - ); - - // Because I am doing day-level syncing this will end up fetching an - // overlapping day of data every time. But if I properly switch this to - // interval-level syncing this should be correct or at the very least - // return a minimal one extra row of data. - $chunk_end = $chunk_begin; - - $this->database->commit_transaction(); - } while ($chunk_begin > $sync_begin); - } - - /** - * Sync forwards from thermostat.sync_end until now. This should be used for - * all syncs except the first one. - * - * @param int $thermostat_id - */ - private function sync_forwards($thermostat_id) { - $thermostat = $this->api('thermostat', 'get', $thermostat_id); - - // Sync from the last sync time until now. Go a couple hours back in time to - // cover that 1 hour delay. - $sync_begin = strtotime($thermostat['sync_end'] . ' -2 hours'); - $sync_end = time(); - - $chunk_begin = $sync_begin; - $chunk_end = $sync_begin; - - // Loop over the dates and do the actual sync. Each chunk is wrapped in a - // transaction for a little bit of protection against exceptions introducing - // bad data and causing the whole sync to fail. - do { - $this->database->start_transaction(); - - $chunk_end = strtotime('+1 week', $chunk_begin); - $chunk_end = min($chunk_end, $sync_end); - - $this->sync_($thermostat['thermostat_id'], $chunk_begin, $chunk_end); - - // Update the thermostat with the current sync range - $this->api( - 'thermostat', - 'update', - [ - 'attributes' => [ - 'thermostat_id' => $thermostat['thermostat_id'], - 'sync_end' => date('Y-m-d H:i:s', $chunk_end) - ] - ] - ); - - $chunk_begin = strtotime('+1 day', $chunk_end); - - $this->database->commit_transaction(); - } while ($chunk_end < $sync_end); - - // Populate at the end of a full sync forwards. - $this->api( - 'runtime_thermostat_summary', - 'populate', - $thermostat_id - ); - } - - /** - * Get the runtime report data for a specified thermostat. Originally this - * was basically a 1:1 import from ecobee. But due to the size of the data - * this now does some fancy logic to merge certain columns down and alter - * their data types just a bit. - * - * @param int $thermostat_id - * @param int $begin - * @param int $end - */ - private function sync_($thermostat_id, $begin, $end) { - $this->user_lock($thermostat_id); - - $thermostat = $this->api('thermostat', 'get', $thermostat_id); - $ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $thermostat['ecobee_thermostat_id']); - - /** - * Round begin/end down to the next 5 minutes. This keep things tidy. - * Without this, I would query for rows between 10:35:16, for example, and - * not get the row at 10:35:00. But the interval function would return it - * and then duplicate databse entry. - */ - $begin = floor($begin / 300) * 300; - $end = floor($end / 300) * 300; - - $begin_interval = $this->get_interval($begin); - $end_interval = $this->get_interval($end); - - $begin_date = date('Y-m-d', $begin); - $end_date = date('Y-m-d', $end); - - $response = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'runtimeReport', - 'arguments' => [ - 'body' => json_encode([ - 'selection' => [ - 'selectionType' => 'thermostats', - 'selectionMatch' => $ecobee_thermostat['identifier'] - ], - 'startDate' => $begin_date, - 'endDate' => $end_date, - 'startInterval' => $begin_interval, - 'endInterval' => $end_interval, - 'columns' => implode(',', self::$ecobee_columns), - 'includeSensors' => false - ]) - ] - ] - ); - - /** - * Read any existing rows from the database so we know if this is an - * insert or an update. Note that even though I have $begin and $end - * already defined, I always look in the database according to what ecobee - * actually returned just in case the returned data goes outside of what I - * requested for some reason. - */ - $columns_begin = $this->get_columns( - $response['reportList'][0]['rowList'][0] - ); - $columns_end = $this->get_columns( - $response['reportList'][0]['rowList'][count($response['reportList'][0]['rowList']) - 1] - ); - - $existing_rows = $this->database->read( - 'runtime_thermostat', - [ - 'thermostat_id' => $thermostat_id, - 'timestamp' => [ - 'value' => [ - $this->get_utc_datetime( - date( - 'Y-m-d H:i:s', - strtotime($columns_begin['date'] . ' ' . $columns_begin['time'] . ' -1 hour') - ), - $thermostat['time_zone'] - ), - $this->get_utc_datetime( - date( - 'Y-m-d H:i:s', - strtotime($columns_end['date'] . ' ' . $columns_end['time'] . ' +1 hour') - ), - $thermostat['time_zone'] - ) - ], - 'operator' => 'between' - ] - ] - ); - - $existing_timestamps = []; - foreach($existing_rows as $existing_row) { - $existing_timestamps[$existing_row['timestamp']] = $existing_row['runtime_thermostat_id']; - } - - // Loop over the ecobee data - foreach ($response['reportList'][0]['rowList'] as $row) { - $columns = $this->get_columns($row); - - /** - * If any of these values are null just throw the whole row away. This - * should very rarely happen as ecobee reports 0 values. The driver of - * this is the summary table...trying to aggregate sums for these values - * is easy, but in order to accurately represent those sums I need to - * know how many rows were included. I would have to store one count per - * sum in the summary table to manage that...or just not store the data - * to begin with. - * - * Also threw in null checks on a bunch of other fields just to simplify - * the code later on. This happens so rarely that throwing away a whole - * row for a null value shouldn't have any noticeable negative effect. - * - * Note: Don't ignore zoneHeatTemp or zoneCoolTemp. Sometimes those are - * legit null as the thermostat may be for heat/cool only (see #160). - */ - if( - $columns['hvacMode'] === null || - $columns['zoneAveTemp'] === null || - $columns['zoneHumidity'] === null || - $columns['outdoorTemp'] === null || - $columns['outdoorHumidity'] === null || - $columns['compHeat1'] === null || - $columns['compHeat2'] === null || - $columns['compCool1'] === null || - $columns['compCool2'] === null || - $columns['auxHeat1'] === null || - $columns['auxHeat2'] === null || - $columns['fan'] === null || - $columns['humidifier'] === null || - $columns['dehumidifier'] === null || - $columns['ventilator'] === null || - $columns['economizer'] === null - ) { - continue; - } - - // Date and time are first two columns of the returned data. It is - // returned in thermostat time, so convert it to UTC first. - $timestamp = $this->get_utc_datetime( - $columns['date'] . ' ' . $columns['time'], - $thermostat['time_zone'] - ); - - $data = []; - - $data['thermostat_id'] = $thermostat_id; - $data['timestamp'] = $timestamp; - - if ($columns['compCool1'] > 0 || $columns['compCool2'] > 0) { - $data['compressor_mode'] = 'cool'; - $data['compressor_1'] = $columns['compCool1'] - $columns['compCool2']; - $data['compressor_2'] = $columns['compCool2']; - } else if ($columns['compHeat1'] > 0 || $columns['compHeat2'] > 0) { - $data['compressor_mode'] = 'heat'; - $data['compressor_1'] = $columns['compHeat1'] - $columns['compHeat2']; - $data['compressor_2'] = $columns['compHeat2']; - } else { - $data['compressor_mode'] = 'off'; - $data['compressor_1'] = 0; - $data['compressor_2'] = 0; - } - - $data['auxiliary_heat_1'] = $columns['auxHeat1'] - $columns['auxHeat2']; - $data['auxiliary_heat_2'] = $columns['auxHeat2']; - - if($columns['humidifier'] > 0) { - $data['accessory_type'] = 'humidifier'; - $data['accessory'] = $columns['humidifier']; - } else if($columns['dehumidifier'] > 0) { - $data['accessory_type'] = 'dehumidifier'; - $data['accessory'] = $columns['dehumidifier']; - } else if($columns['ventilator'] > 0) { - $data['accessory_type'] = 'ventilator'; - $data['accessory'] = $columns['ventilator']; - } else if($columns['economizer'] > 0) { - $data['accessory_type'] = 'economizer'; - $data['accessory'] = $columns['economizer']; - } else { - $data['accessory_type'] = 'off'; - $data['accessory'] = 0; - } - - // Ecobee does not report fan usage when it does not control the fan, so - // this will mark the fan as running when certain equipment is on. - $data['fan'] = max( - $columns['fan'], - $data['compressor_1'], - $data['compressor_2'], - $data['auxiliary_heat_1'], - $data['auxiliary_heat_2'], - $data['accessory'] - ); - - $system_modes = [ - 'auto' => 'auto', - 'cool' => 'cool', - 'heat' => 'heat', - 'auxHeatOnly' => 'auxiliary_heat', - 'off' => 'off' - ]; - $data['system_mode'] = $system_modes[$columns['hvacMode']]; - - $data['indoor_temperature'] = $columns['zoneAveTemp'] * 10; - $data['indoor_humidity'] = round($columns['zoneHumidity']); - - $data['outdoor_temperature'] = $columns['outdoorTemp'] * 10; - $data['outdoor_humidity'] = round($columns['outdoorHumidity']); - - $data['event_runtime_thermostat_text_id'] = $this->api( - 'runtime_thermostat_text', - 'get_create', - $columns['zoneCalendarEvent'] - )['runtime_thermostat_text_id']; - $data['climate_runtime_thermostat_text_id'] = $this->api( - 'runtime_thermostat_text', - 'get_create', - $columns['zoneClimate'] - )['runtime_thermostat_text_id']; - - $data['setpoint_cool'] = $columns['zoneCoolTemp'] * 10; - $data['setpoint_heat'] = $columns['zoneHeatTemp'] * 10; - - // Create or update the database - if(isset($existing_timestamps[$timestamp]) === true) { - $data['runtime_thermostat_id'] = $existing_timestamps[$timestamp]; - $this->database->update('runtime_thermostat', $data, 'id'); - } - else { - $existing_timestamps[$timestamp] = $this->database->create('runtime_thermostat', $data, 'id'); - } - } - } - - /** - * Get the ecobee "interval" value from a timestamp. - * - * @param string $timestamp The timestamp. - * - * @return int The interval. - */ - private function get_interval($timestamp) { - $hours = date('G', $timestamp); - $minutes = date('i', $timestamp); - - return ($hours * 12) + floor($minutes / 5); - } - - /** - * Convert a string CSV row to cleaned up array of columns. - * - * @param string $row - * - * @return array - */ - private function get_columns($row) { - $return = []; - - $columns = explode(',', substr($row, 0, -1)); - $return['date'] = array_splice($columns, 0, 1)[0]; - $return['time'] = array_splice($columns, 0, 1)[0]; - - for($i = 0; $i < count($columns); $i++) { - $columns[$i] = trim($columns[$i]); - $return[self::$ecobee_columns[$i]] = $columns[$i] === '' ? null : $columns[$i]; - } - - return $return; - } - - /** - * Convert a local datetime string to a UTC datetime string. - * - * @param string $local_datetime Local datetime string. - * @param string $local_time_zone The local time zone to convert from. - * - * @return string The UTC datetime string. - */ - private function get_utc_datetime($local_datetime, $local_time_zone) { - $local_time_zone = new DateTimeZone($local_time_zone); - $utc_time_zone = new DateTimeZone('UTC'); - $date_time = new DateTime($local_datetime, $local_time_zone); - $date_time->setTimezone($utc_time_zone); - - return $date_time->format('Y-m-d H:i:s'); - } - - /** - * Convert a UTC datetime string to a UTC datetime string. - * - * @param string $utc_datetime Local datetime string. - * @param string $local_time_zone The local time zone to convert from. - * - * @return string The UTC datetime string. - */ - private function get_local_datetime($utc_datetime, $local_time_zone) { - $local_time_zone = new DateTimeZone($local_time_zone); - $utc_time_zone = new DateTimeZone('UTC'); - $date_time = new DateTime($utc_datetime, $utc_time_zone); - $date_time->setTimezone($local_time_zone); - - return $date_time->format('Y-m-d H:i:s'); - } - /** * Read data from the runtime_thermostat table. Basically just a crud read * but has custom security for thermostat_id since user_id is not on the @@ -696,131 +156,6 @@ class runtime_thermostat extends cora\crud { return $runtime_thermostats; } - /** - * Download all data that exists for a specific thermostat. - * - * @param int $thermostat_id - * @param string $download_begin Optional; the date to begin the download. - * @param string $download_end Optional; the date to end the download. - */ - public function download($thermostat_id, $download_begin = null, $download_end = null) { - set_time_limit(120); - - $this->user_lock($thermostat_id); - - $thermostat = $this->api('thermostat', 'get', $thermostat_id); - $ecobee_thermostat = $this->api( - 'ecobee_thermostat', - 'get', - $thermostat['ecobee_thermostat_id'] - ); - - if($download_begin === null) { - $download_begin = strtotime($thermostat['first_connected']); - } else { - $download_begin = strtotime($download_begin); - } - - if($download_end === null) { - $download_end = time(); - } else { - $download_end = strtotime($download_end); - } - - $chunk_begin = $download_begin; - $chunk_end = $download_begin; - - $bytes = 0; - - $output = fopen('php://output', 'w'); - $needs_header = true; - do { - $chunk_end = strtotime('+1 week', $chunk_begin); - $chunk_end = min($chunk_end, $download_end); - - $runtime_thermostats = $this->database->read( - 'runtime_thermostat', - [ - 'thermostat_id' => $thermostat_id, - 'timestamp' => [ - 'value' => [date('Y-m-d H:i:s', $chunk_begin), date('Y-m-d H:i:s', $chunk_end)] , - 'operator' => 'between' - ] - ], - [], - 'timestamp' // order by - ); - - // Get the appropriate runtime_thermostat_texts. - $runtime_thermostat_text_ids = array_unique(array_merge( - array_column($runtime_thermostats, 'event_runtime_thermostat_text_id'), - array_column($runtime_thermostats, 'climate_runtime_thermostat_text_id') - )); - $runtime_thermostat_texts = $this->api( - 'runtime_thermostat_text', - 'read_id', - [ - 'attributes' => [ - 'runtime_thermostat_text_id' => $runtime_thermostat_text_ids - ] - ] - ); - - - if ($needs_header === true && count($runtime_thermostats) > 0) { - $headers = array_keys($runtime_thermostats[0]); - - // Remove the IDs and rename two columns. - unset($headers[array_search('runtime_thermostat_id', $headers)]); - unset($headers[array_search('thermostat_id', $headers)]); - $headers[array_search('event_runtime_thermostat_text_id', $headers)] = 'event'; - $headers[array_search('climate_runtime_thermostat_text_id', $headers)] = 'climate'; - - $bytes += fputcsv($output, $headers); - $needs_header = false; - } - - foreach($runtime_thermostats as $runtime_thermostat) { - unset($runtime_thermostat['runtime_thermostat_id']); - unset($runtime_thermostat['thermostat_id']); - - $runtime_thermostat['timestamp'] = $this->get_local_datetime( - $runtime_thermostat['timestamp'], - $thermostat['time_zone'] - ); - - // Return temperatures in a human-readable format. - foreach(['indoor_temperature', 'outdoor_temperature', 'setpoint_heat', 'setpoint_cool'] as $key) { - if($runtime_thermostat[$key] !== null) { - $runtime_thermostat[$key] /= 10; - } - } - - // Replace event and climate with their string values. - if ($runtime_thermostat['event_runtime_thermostat_text_id'] !== null) { - $runtime_thermostat['event_runtime_thermostat_text_id'] = $runtime_thermostat_texts[$runtime_thermostat['event_runtime_thermostat_text_id']]['value']; - } - - if ($runtime_thermostat['climate_runtime_thermostat_text_id'] !== null) { - $runtime_thermostat['climate_runtime_thermostat_text_id'] = $runtime_thermostat_texts[$runtime_thermostat['climate_runtime_thermostat_text_id']]['value']; - } - - $bytes += fputcsv($output, $runtime_thermostat); - } - - $chunk_begin = strtotime('+1 day', $chunk_end); - } while ($chunk_end < $download_end); - fclose($output); - - $this->cora->set_headers([ - 'Content-Type' => 'text/csv', - 'Content-Length' => $bytes, - 'Content-Disposition' => 'attachment; filename="Beestat Export - ' . $ecobee_thermostat['identifier'] . '.csv"', - 'Pragma' => 'no-cache', - 'Expires' => '0', - ], true); - } - /** * Since this table does not have a user_id column, security must be handled * manually. Call this with a thermostat_id to verify that the current user diff --git a/css/dashboard.css b/css/dashboard.css index 1a3d041..f1822a2 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -350,12 +350,12 @@ a.inverted:active { .icon.eye_off:before { content: "\F209"; } .icon.fan:before { content: "\F210"; } .icon.fire:before { content: "\F238"; } -.icon.gauge:before { content: "\F29A"; } .icon.google_play:before { content: "\F2BC"; } .icon.heart:before { content: "\F2D1"; } .icon.help_circle:before { content: "\F2D7"; } .icon.home:before { content: "\F2DC"; } .icon.home_floor_a:before { content: "\FD5F"; } +.icon.home_group:before { content: "\FDB0"; } .icon.information:before { content: "\F2FC"; } .icon.key:before { content: "\F306"; } .icon.layers:before { content: "\F328"; } @@ -371,6 +371,8 @@ a.inverted:active { .icon.pound:before { content: "\F423"; } .icon.snowflake:before { content: "\F716"; } .icon.swap_horizontal:before { content: "\F4E1"; } +.icon.signal_variant:before { content: "\F60A"; } +.icon.tablet_dashboard:before { content: "\FEEB"; } .icon.thermostat:before { content: "\F393"; } .icon.thumb_up:before { content: "\F513"; } .icon.tune:before { content: "\F62E"; } diff --git a/js/beestat.js b/js/beestat.js index f22a401..7ea6d59 100644 --- a/js/beestat.js +++ b/js/beestat.js @@ -143,8 +143,8 @@ if ('serviceWorker' in navigator) { beestat.width = window.innerWidth; window.addEventListener('resize', rocket.throttle(100, function() { var breakpoints = [ - 500, - 600 + 600, + 650 ]; breakpoints.forEach(function(breakpoint) { diff --git a/js/beestat/setting.js b/js/beestat/setting.js index 3e64300..fabd332 100644 --- a/js/beestat/setting.js +++ b/js/beestat/setting.js @@ -12,13 +12,21 @@ beestat.setting = function(key, opt_value, opt_callback) { var user = beestat.get_user(); var defaults = { - 'runtime_detail_smoothing': true, - 'runtime_detail_range_type': 'dynamic', - 'runtime_detail_range_static_begin': moment() + 'runtime_thermostat_detail_smoothing': true, + 'runtime_thermostat_detail_range_type': 'dynamic', + 'runtime_thermostat_detail_range_static_begin': moment() .subtract(3, 'day') .format('MM/DD/YYYY'), - 'runtime_detail_range_static_end': moment().format('MM/DD/YYYY'), - 'runtime_detail_range_dynamic': 3, + 'runtime_thermostat_detail_range_static_end': moment().format('MM/DD/YYYY'), + 'runtime_thermostat_detail_range_dynamic': 3, + + 'runtime_sensor_detail_smoothing': true, + 'runtime_sensor_detail_range_type': 'dynamic', + 'runtime_sensor_detail_range_static_begin': moment() + .subtract(3, 'day') + .format('MM/DD/YYYY'), + 'runtime_sensor_detail_range_static_end': moment().format('MM/DD/YYYY'), + 'runtime_sensor_detail_range_dynamic': 3, 'runtime_thermostat_summary_time_count': 0, 'runtime_thermostat_summary_time_period': 'all', diff --git a/js/component/card/runtime_sensor_detail.js b/js/component/card/runtime_sensor_detail.js new file mode 100644 index 0000000..57f761a --- /dev/null +++ b/js/component/card/runtime_sensor_detail.js @@ -0,0 +1,528 @@ +/** + * Runtime detail card. Shows a graph similar to what ecobee shows with the + * runtime info for a recent period of time. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for + */ +beestat.component.card.runtime_sensor_detail = function(thermostat_id) { + var self = this; + + this.thermostat_id_ = thermostat_id; + + /* + * When a setting is changed clear all of the data. Then rerender which will + * trigger the loading state. Also do this when the cache changes. + * + * 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 change_function = beestat.debounce(function() { + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'setting.runtime_sensor_detail_smoothing', + 'setting.runtime_sensor_detail_range_type', + 'setting.runtime_sensor_detail_range_dynamic', + 'cache.runtime_sensor' + ], + change_function + ); + + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.runtime_sensor_detail, beestat.component.card); + +/** + * Decorate + * + * @param {rocket.ELements} parent + */ +beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = function(parent) { + var self = this; + + var data = this.get_data_(); + + this.chart_ = new beestat.component.chart.runtime_sensor_detail(data); + this.chart_.render(parent); + + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + var required_begin; + var required_end; + if (beestat.setting('runtime_sensor_detail_range_type') === 'dynamic') { + required_begin = moment() + .subtract( + beestat.setting('runtime_sensor_detail_range_dynamic'), + 'day' + ) + .second(0); + + required_end = moment() + .subtract(1, 'hour') + .second(0); + } else { + required_begin = moment( + beestat.setting('runtime_sensor_detail_range_static_begin') + ' 00:00:00' + ); + required_end = moment( + beestat.setting('runtime_sensor_detail_range_static_end') + ' 23:59:59' + ); + } + + // Don't go before there's data. + required_begin = moment.max( + required_begin, + moment(thermostat.first_connected) + ); + + // Don't go after now. + required_end = moment.min( + required_end, + moment().subtract(1, 'hour') + ); + + /** + * If the needed data exists in the database and the runtime_sensor + * cache is empty, then query the data. If the needed data does not exist in + * the database, check every 2 seconds until it does. + */ + if (this.data_synced_(required_begin, required_end) === true) { + if (beestat.cache.runtime_sensor === undefined) { + this.show_loading_('Loading Runtime Detail'); + + var value; + var operator; + + if (beestat.setting('runtime_sensor_detail_range_type') === 'dynamic') { + value = required_begin.format(); + operator = '>='; + } else { + value = [ + required_begin.format(), + required_end.format() + ]; + operator = 'between'; + } + + var api_call = new beestat.api(); + Object.values(beestat.cache.sensor).forEach(function(sensor) { + if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + api_call.add_call( + 'runtime_sensor', + 'read', + { + 'attributes': { + 'sensor_id': sensor.sensor_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + } + ); + } + }); + + api_call.set_callback(function(response) { + var runtime_sensors = []; + response.forEach(function(r) { + runtime_sensors = runtime_sensors.concat(r); + }); + beestat.cache.set('runtime_sensor', runtime_sensors); + }); + + api_call.send(); + } + } else { + this.show_loading_('Syncing Runtime Detail'); + setTimeout(function() { + new beestat.api() + .add_call( + 'thermostat', + 'read_id', + {}, + 'thermostat' + ) + .set_callback(function(response) { + beestat.cache.set('thermostat', response); + self.rerender(); + }) + .send(); + }, 2000); + } +}; + +/** + * Decorate the menu + * + * @param {rocket.Elements} parent + */ +beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = function(parent) { + var self = this; + + var menu = (new beestat.component.menu()).render(parent); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 1 Day') + .set_icon('numeric_1_box') + .set_callback(function() { + if ( + beestat.setting('runtime_sensor_detail_range_dynamic') !== 1 || + beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_sensor'); + beestat.setting({ + 'runtime_sensor_detail_range_dynamic': 1, + 'runtime_sensor_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 3 Days') + .set_icon('numeric_3_box') + .set_callback(function() { + if ( + beestat.setting('runtime_sensor_detail_range_dynamic') !== 3 || + beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_sensor'); + beestat.setting({ + 'runtime_sensor_detail_range_dynamic': 3, + 'runtime_sensor_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 7 Days') + .set_icon('numeric_7_box') + .set_callback(function() { + if ( + beestat.setting('runtime_sensor_detail_range_dynamic') !== 7 || + beestat.setting('runtime_sensor_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_sensor'); + beestat.setting({ + 'runtime_sensor_detail_range_dynamic': 7, + 'runtime_sensor_detail_range_type': 'dynamic' + }); + } + })); + + 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('Download Chart') + .set_icon('download') + .set_callback(function() { + self.chart_.export(); + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Reset Zoom') + .set_icon('magnify_minus') + .set_callback(function() { + self.chart_.reset_zoom(); + })); + + if (beestat.setting('runtime_sensor_detail_smoothing') === true) { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Disable Smothing') + .set_icon('chart_line') + .set_callback(function() { + beestat.setting('runtime_sensor_detail_smoothing', false); + })); + } else { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Enable Smoothing') + .set_icon('chart_bell_curve') + .set_callback(function() { + beestat.setting('runtime_sensor_detail_smoothing', true); + })); + } + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Help') + .set_icon('help_circle') + .set_callback(function() { + window.open('https://www.notion.so/beestat/891f94a6bdb34895a453b7b91591ec29'); + })); +}; + +/** + * Get all of the series data. + * + * @return {object} The series data. + */ +beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function() { + var self = this; + + var data = { + 'x': [], + 'series': {}, + 'metadata': { + 'series': {}, + 'chart': { + 'title': this.get_title_(), + 'subtitle': this.get_subtitle_(), + 'y_min': Infinity, + 'y_max': -Infinity + } + } + }; + + // A couple private helper functions for manipulating the min/max y values. + var y_min_max = function(value) { + if (value !== null) { + data.metadata.chart.y_min = Math.min(data.metadata.chart.y_min, value); + data.metadata.chart.y_max = Math.max(data.metadata.chart.y_max, value); + } + }; + + var series_codes = []; + Object.values(beestat.cache.sensor).forEach(function(sensor) { + if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + series_codes.push('temperature_' + sensor.sensor_id); + series_codes.push('occupancy_' + sensor.sensor_id); + } + }); + series_codes.push('dummy'); + + // Initialize a bunch of stuff. + var sequential = {}; + series_codes.forEach(function(series_code) { + sequential[series_code] = 0; + + data.series[series_code] = []; + data.metadata.series[series_code] = { + 'active': false + }; + if (series_code === 'dummy') { + data.metadata.series[series_code].name = null; + } else { + var sensor_id = series_code.replace(/[^0-9]/g, ''); + data.metadata.series[series_code].name = beestat.cache.sensor[sensor_id].name; + } + }); + + var begin_m; + var end_m; + if (beestat.setting('runtime_sensor_detail_range_type') === 'dynamic') { + begin_m = moment().subtract( + beestat.setting('runtime_sensor_detail_range_dynamic'), + 'day' + ); + end_m = moment().subtract(1, 'hour'); + } else { + begin_m = moment( + beestat.setting('runtime_sensor_detail_range_static_begin') + ' 00:00:00' + ); + end_m = moment( + beestat.setting('runtime_sensor_detail_range_static_end') + ' 23:59:59' + ); + } + + // TODO: This needs to be max of begin and when I actually have sensor data + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + begin_m = moment.max( + begin_m, + moment(thermostat.first_connected) + ); + + begin_m + .minute(Math.ceil(begin_m.minute() / 5) * 5) + .second(0) + .millisecond(0); + + var runtime_sensors = this.get_runtime_sensor_by_date_(); + + // Initialize moving average. + var moving = []; + var moving_count; + if (beestat.setting('runtime_sensor_detail_smoothing') === true) { + moving_count = 15; + } else { + moving_count = 1; + } + var offset; + for (var i = 0; i < moving_count; i++) { + offset = (i - Math.floor(moving_count / 2)) * 300000; + moving.push(runtime_sensors[begin_m.valueOf() + offset]); + } + + // Loop. + var current_m = begin_m; + while ( + // beestat.cache.runtime_sensor.length > 0 && + current_m.isSameOrAfter(end_m) === false + ) { + data.x.push(current_m.clone()); + + // Without this series the chart will jump to the nearest value if there is a chunk of missing data. + data.series.dummy.push(1); + data.metadata.series.dummy.active = true; + + if (runtime_sensors[current_m.valueOf()] !== undefined) { + runtime_sensors[current_m.valueOf()].forEach(function(runtime_sensor, j) { + var temperature_moving = beestat.temperature( + self.get_average_(moving, j) + ); + data.series['temperature_' + runtime_sensor.sensor_id].push(temperature_moving); + y_min_max(temperature_moving); + data.metadata.series['temperature_' + runtime_sensor.sensor_id].active = true; + + if (runtime_sensor.occupancy === true) { + let swimlane_properties = + beestat.component.chart.runtime_sensor_detail.get_swimlane_properties( + runtime_sensors[current_m.valueOf()].length, + j + ); + + sequential['occupancy_' + runtime_sensor.sensor_id]++; + data.series['occupancy_' + runtime_sensor.sensor_id].push(swimlane_properties.y); + } else { + if (sequential['occupancy_' + runtime_sensor.sensor_id] > 0) { + let swimlane_properties = + beestat.component.chart.runtime_sensor_detail.get_swimlane_properties( + runtime_sensors[current_m.valueOf()].length, + j + ); + + data.series['occupancy_' + runtime_sensor.sensor_id].push(swimlane_properties.y); + } else { + data.series['occupancy_' + runtime_sensor.sensor_id].push(null); + } + sequential['occupancy_' + runtime_sensor.sensor_id] = 0; + } + }); + } else { + Object.values(beestat.cache.sensor).forEach(function(sensor) { + if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + data.series['temperature_' + sensor.sensor_id].push(null); + data.series['occupancy_' + sensor.sensor_id].push(null); + } + }); + } + + current_m.add(5, 'minute'); + + /** + * Remove the first row in the moving average and add the next one. Yes + * this could introduce undefined values; that's ok. Those are handled in + * the get_average_ function. + */ + moving.shift(); + moving.push(runtime_sensors[current_m.valueOf() + offset]); + } + + return data; +}; + +/** + * Get all the runtime_sensor rows indexed by date. + * + * @return {array} The runtime_sensor rows. + */ +beestat.component.card.runtime_sensor_detail.prototype.get_runtime_sensor_by_date_ = function() { + var runtime_sensors = {}; + if (beestat.cache.runtime_sensor !== undefined) { + beestat.cache.runtime_sensor.forEach(function(runtime_sensor) { + var timestamp = [moment(runtime_sensor.timestamp).valueOf()]; + if (runtime_sensors[timestamp] === undefined) { + runtime_sensors[timestamp] = []; + } + runtime_sensors[timestamp].push(runtime_sensor); + }); + } + return runtime_sensors; +}; + +/** + * Given an array of runtime_sensors, get the average value of one of the + * keys. Allows and ignores undefined values in order to keep a more accurate + * moving average. + * + * @param {array} runtime_sensors + * @param {string} j The index in the sub-array + * + * @return {number} The average. + */ +beestat.component.card.runtime_sensor_detail.prototype.get_average_ = function(runtime_sensors, j) { + var average = 0; + var count = 0; + for (var i = 0; i < runtime_sensors.length; i++) { + if (runtime_sensors[i] !== undefined) { + average += runtime_sensors[i][j].temperature; + count++; + } + } + return average / count; +}; + +/** + * Get the title of the card. + * + * @return {string} Title + */ +beestat.component.card.runtime_sensor_detail.prototype.get_title_ = function() { + return 'Runtime Detail'; +}; + +/** + * Get the subtitle of the card. + * + * @return {string} Subtitle + */ +beestat.component.card.runtime_sensor_detail.prototype.get_subtitle_ = function() { + if (beestat.setting('runtime_sensor_detail_range_type') === 'dynamic') { + var s = (beestat.setting('runtime_sensor_detail_range_dynamic') > 1) ? 's' : ''; + + return 'Past ' + + beestat.setting('runtime_sensor_detail_range_dynamic') + + ' day' + + s; + } + + var begin = moment(beestat.setting('runtime_sensor_detail_range_static_begin')) + .format('MMM D, YYYY'); + var end = moment(beestat.setting('runtime_sensor_detail_range_static_end')) + .format('MMM D, YYYY'); + + return begin + ' to ' + end; +}; + +/** + * Determine whether or not the data to render the desired date range has been + * synced. + * + * @param {moment} required_sync_begin + * @param {moment} required_sync_end + * + * @return {boolean} Whether or not the data is synced. + */ +beestat.component.card.runtime_sensor_detail.prototype.data_synced_ = function(required_sync_begin, required_sync_end) { + // Demo can just grab whatever data is there. + if (window.is_demo === true) { + return true; + } + + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var current_sync_begin = moment.utc(thermostat.sync_begin); + var current_sync_end = moment.utc(thermostat.sync_end); + + return ( + current_sync_begin.isSameOrBefore(required_sync_begin) && + current_sync_end.isSameOrAfter(required_sync_end) + ); +}; diff --git a/js/component/card/runtime_detail.js b/js/component/card/runtime_thermostat_detail.js similarity index 86% rename from js/component/card/runtime_detail.js rename to js/component/card/runtime_thermostat_detail.js index a6fc287..c141514 100644 --- a/js/component/card/runtime_detail.js +++ b/js/component/card/runtime_thermostat_detail.js @@ -5,7 +5,7 @@ * @param {number} thermostat_id The thermostat_id this card is displaying * data for */ -beestat.component.card.runtime_detail = function(thermostat_id) { +beestat.component.card.runtime_thermostat_detail = function(thermostat_id) { var self = this; this.thermostat_id_ = thermostat_id; @@ -24,9 +24,9 @@ beestat.component.card.runtime_detail = function(thermostat_id) { beestat.dispatcher.addEventListener( [ - 'setting.runtime_detail_smoothing', - 'setting.runtime_detail_range_type', - 'setting.runtime_detail_range_dynamic', + 'setting.runtime_thermostat_detail_smoothing', + 'setting.runtime_thermostat_detail_range_type', + 'setting.runtime_thermostat_detail_range_dynamic', 'cache.runtime_thermostat' ], change_function @@ -34,28 +34,28 @@ beestat.component.card.runtime_detail = function(thermostat_id) { beestat.component.card.apply(this, arguments); }; -beestat.extend(beestat.component.card.runtime_detail, beestat.component.card); +beestat.extend(beestat.component.card.runtime_thermostat_detail, beestat.component.card); /** * Decorate * * @param {rocket.ELements} parent */ -beestat.component.card.runtime_detail.prototype.decorate_contents_ = function(parent) { +beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = function(parent) { var self = this; var data = this.get_data_(); - this.chart_ = new beestat.component.chart.runtime_detail(data); + this.chart_ = new beestat.component.chart.runtime_thermostat_detail(data); this.chart_.render(parent); var thermostat = beestat.cache.thermostat[this.thermostat_id_]; var required_begin; var required_end; - if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + if (beestat.setting('runtime_thermostat_detail_range_type') === 'dynamic') { required_begin = moment() .subtract( - beestat.setting('runtime_detail_range_dynamic'), + beestat.setting('runtime_thermostat_detail_range_dynamic'), 'day' ) .second(0); @@ -65,10 +65,10 @@ beestat.component.card.runtime_detail.prototype.decorate_contents_ = function(pa .second(0); } else { required_begin = moment( - beestat.setting('runtime_detail_range_static_begin') + ' 00:00:00' + beestat.setting('runtime_thermostat_detail_range_static_begin') + ' 00:00:00' ); required_end = moment( - beestat.setting('runtime_detail_range_static_end') + ' 23:59:59' + beestat.setting('runtime_thermostat_detail_range_static_end') + ' 23:59:59' ); } @@ -96,7 +96,7 @@ beestat.component.card.runtime_detail.prototype.decorate_contents_ = function(pa var value; var operator; - if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + if (beestat.setting('runtime_thermostat_detail_range_type') === 'dynamic') { value = required_begin.format(); operator = '>='; } else { @@ -150,7 +150,7 @@ beestat.component.card.runtime_detail.prototype.decorate_contents_ = function(pa * * @param {rocket.Elements} parent */ -beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(parent) { +beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = function(parent) { var self = this; var menu = (new beestat.component.menu()).render(parent); @@ -160,13 +160,13 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p .set_icon('numeric_1_box') .set_callback(function() { if ( - beestat.setting('runtime_detail_range_dynamic') !== 1 || - beestat.setting('runtime_detail_range_type') !== 'dynamic' + beestat.setting('runtime_thermostat_detail_range_dynamic') !== 1 || + beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { beestat.cache.delete('runtime_thermostat'); beestat.setting({ - 'runtime_detail_range_dynamic': 1, - 'runtime_detail_range_type': 'dynamic' + 'runtime_thermostat_detail_range_dynamic': 1, + 'runtime_thermostat_detail_range_type': 'dynamic' }); } })); @@ -176,13 +176,13 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p .set_icon('numeric_3_box') .set_callback(function() { if ( - beestat.setting('runtime_detail_range_dynamic') !== 3 || - beestat.setting('runtime_detail_range_type') !== 'dynamic' + beestat.setting('runtime_thermostat_detail_range_dynamic') !== 3 || + beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { beestat.cache.delete('runtime_thermostat'); beestat.setting({ - 'runtime_detail_range_dynamic': 3, - 'runtime_detail_range_type': 'dynamic' + 'runtime_thermostat_detail_range_dynamic': 3, + 'runtime_thermostat_detail_range_type': 'dynamic' }); } })); @@ -192,13 +192,13 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p .set_icon('numeric_7_box') .set_callback(function() { if ( - beestat.setting('runtime_detail_range_dynamic') !== 7 || - beestat.setting('runtime_detail_range_type') !== 'dynamic' + beestat.setting('runtime_thermostat_detail_range_dynamic') !== 7 || + beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic' ) { beestat.cache.delete('runtime_thermostat'); beestat.setting({ - 'runtime_detail_range_dynamic': 7, - 'runtime_detail_range_type': 'dynamic' + 'runtime_thermostat_detail_range_dynamic': 7, + 'runtime_thermostat_detail_range_type': 'dynamic' }); } })); @@ -207,7 +207,7 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p .set_text('Custom') .set_icon('calendar_edit') .set_callback(function() { - (new beestat.component.modal.runtime_detail_custom()).render(); + (new beestat.component.modal.runtime_thermostat_detail_custom()).render(); })); menu.add_menu_item(new beestat.component.menu_item() @@ -224,19 +224,19 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p self.chart_.reset_zoom(); })); - if (beestat.setting('runtime_detail_smoothing') === true) { + if (beestat.setting('runtime_thermostat_detail_smoothing') === true) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Disable Smothing') .set_icon('chart_line') .set_callback(function() { - beestat.setting('runtime_detail_smoothing', false); + beestat.setting('runtime_thermostat_detail_smoothing', false); })); } else { menu.add_menu_item(new beestat.component.menu_item() .set_text('Enable Smoothing') .set_icon('chart_bell_curve') .set_callback(function() { - beestat.setting('runtime_detail_smoothing', true); + beestat.setting('runtime_thermostat_detail_smoothing', true); })); } @@ -253,7 +253,7 @@ beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(p * * @return {object} The series data. */ -beestat.component.card.runtime_detail.prototype.get_data_ = function() { +beestat.component.card.runtime_thermostat_detail.prototype.get_data_ = function() { var data = { 'x': [], 'series': {}, @@ -352,7 +352,7 @@ beestat.component.card.runtime_detail.prototype.get_data_ = function() { * Figure out what date range to use. * var begin_m = moment() * .subtract( - * beestat.setting('runtime_detail_range_dynamic'), + * beestat.setting('runtime_thermostat_detail_range_dynamic'), * 'day' * ); * begin_m @@ -364,18 +364,18 @@ beestat.component.card.runtime_detail.prototype.get_data_ = function() { var begin_m; var end_m; - if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + if (beestat.setting('runtime_thermostat_detail_range_type') === 'dynamic') { begin_m = moment().subtract( - beestat.setting('runtime_detail_range_dynamic'), + beestat.setting('runtime_thermostat_detail_range_dynamic'), 'day' ); end_m = moment().subtract(1, 'hour'); } else { begin_m = moment( - beestat.setting('runtime_detail_range_static_begin') + ' 00:00:00' + beestat.setting('runtime_thermostat_detail_range_static_begin') + ' 00:00:00' ); end_m = moment( - beestat.setting('runtime_detail_range_static_end') + ' 23:59:59' + beestat.setting('runtime_thermostat_detail_range_static_end') + ' 23:59:59' ); } @@ -395,7 +395,7 @@ beestat.component.card.runtime_detail.prototype.get_data_ = function() { // Initialize moving average. var moving = []; var moving_count; - if (beestat.setting('runtime_detail_smoothing') === true) { + if (beestat.setting('runtime_thermostat_detail_smoothing') === true) { moving_count = 15; } else { moving_count = 1; @@ -757,7 +757,7 @@ beestat.component.card.runtime_detail.prototype.get_data_ = function() { * * @return {array} The runtime_thermostat rows. */ -beestat.component.card.runtime_detail.prototype.get_runtime_thermostat_by_date_ = function() { +beestat.component.card.runtime_thermostat_detail.prototype.get_runtime_thermostat_by_date_ = function() { var runtime_thermostats = {}; if (beestat.cache.runtime_thermostat !== undefined) { beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat) { @@ -777,7 +777,7 @@ beestat.component.card.runtime_detail.prototype.get_runtime_thermostat_by_date_ * * @return {number} The average. */ -beestat.component.card.runtime_detail.prototype.get_average_ = function(runtime_thermostats, series_code) { +beestat.component.card.runtime_thermostat_detail.prototype.get_average_ = function(runtime_thermostats, series_code) { var average = 0; var count = 0; for (var i = 0; i < runtime_thermostats.length; i++) { @@ -794,7 +794,7 @@ beestat.component.card.runtime_detail.prototype.get_average_ = function(runtime_ * * @return {string} Title */ -beestat.component.card.runtime_detail.prototype.get_title_ = function() { +beestat.component.card.runtime_thermostat_detail.prototype.get_title_ = function() { return 'Runtime Detail'; }; @@ -803,19 +803,19 @@ beestat.component.card.runtime_detail.prototype.get_title_ = function() { * * @return {string} Subtitle */ -beestat.component.card.runtime_detail.prototype.get_subtitle_ = function() { - if (beestat.setting('runtime_detail_range_type') === 'dynamic') { - var s = (beestat.setting('runtime_detail_range_dynamic') > 1) ? 's' : ''; +beestat.component.card.runtime_thermostat_detail.prototype.get_subtitle_ = function() { + if (beestat.setting('runtime_thermostat_detail_range_type') === 'dynamic') { + var s = (beestat.setting('runtime_thermostat_detail_range_dynamic') > 1) ? 's' : ''; return 'Past ' + - beestat.setting('runtime_detail_range_dynamic') + + beestat.setting('runtime_thermostat_detail_range_dynamic') + ' day' + s; } - var begin = moment(beestat.setting('runtime_detail_range_static_begin')) + var begin = moment(beestat.setting('runtime_thermostat_detail_range_static_begin')) .format('MMM D, YYYY'); - var end = moment(beestat.setting('runtime_detail_range_static_end')) + var end = moment(beestat.setting('runtime_thermostat_detail_range_static_end')) .format('MMM D, YYYY'); return begin + ' to ' + end; @@ -830,7 +830,7 @@ beestat.component.card.runtime_detail.prototype.get_subtitle_ = function() { * * @return {boolean} Whether or not the data is synced. */ -beestat.component.card.runtime_detail.prototype.data_synced_ = function(required_sync_begin, required_sync_end) { +beestat.component.card.runtime_thermostat_detail.prototype.data_synced_ = function(required_sync_begin, required_sync_end) { // Demo can just grab whatever data is there. if (window.is_demo === true) { return true; diff --git a/js/component/chart/runtime_sensor_detail.js b/js/component/chart/runtime_sensor_detail.js new file mode 100644 index 0000000..84bb3d3 --- /dev/null +++ b/js/component/chart/runtime_sensor_detail.js @@ -0,0 +1,281 @@ +/** + * Runtime sensor detail chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.runtime_sensor_detail = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.runtime_sensor_detail, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.runtime_sensor_detail.prototype.get_options_xAxis_labels_formatter_ = function() { + var current_day; + var current_hour; + + return function() { + var hour = this.value.format('ha'); + var day = this.value.format('ddd'); + + var label_parts = []; + if (day !== current_day) { + label_parts.push(day); + } + if (hour !== current_hour) { + label_parts.push(hour); + } + + current_hour = hour; + current_day = day; + + return label_parts.join(' '); + }; +}; + +beestat.component.chart.runtime_sensor_detail.prototype.get_options_legend_labelFormatter_ = function() { + var self = this; + return function() { + return self.data_.metadata.series[this.name].name; + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.runtime_sensor_detail.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + var colors = [ + beestat.style.color.blue.base, + beestat.style.color.red.base, + beestat.style.color.yellow.base, + beestat.style.color.green.base, + beestat.style.color.orange.base, + beestat.style.color.bluegreen.base, + beestat.style.color.purple.base, + beestat.style.color.lightblue.base + ]; + + Object.values(beestat.cache.sensor).forEach(function(sensor, i) { + if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + series.push({ + 'name': 'temperature_' + sensor.sensor_id, + 'data': self.data_.series['temperature_' + sensor.sensor_id], + 'color': colors[i], + 'yAxis': 0, + 'type': 'spline', + 'lineWidth': 1 + }); + + var sensor_count = (Object.keys(self.data_.series).length - 1) / 2; + + series.push({ + 'linkedTo': ':previous', + 'name': 'occupancy_' + sensor.sensor_id, + 'data': self.data_.series['occupancy_' + sensor.sensor_id], + 'color': colors[i], + 'yAxis': 1, + 'type': 'line', + 'lineWidth': beestat.component.chart.runtime_sensor_detail.get_swimlane_properties(sensor_count, 1).line_width, + 'linecap': 'square', + 'className': 'crisp_edges' + }); + } + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 1, + 'type': 'line', + 'lineWidth': 0, + 'showInLegend': false + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.runtime_sensor_detail.prototype.get_options_yAxis_ = function() { + /** + * Highcharts doesn't seem to respect axis behavior well so just overriding + * it completely here. + */ + + var sensor_count = (Object.keys(this.data_.series).length - 1) / 2; + + var y_min = Math.floor((this.data_.metadata.chart.y_min - 5) / 10) * 10; + var y_max = Math.ceil((this.data_.metadata.chart.y_max + 10) / 10) * 10; + + /** + * This is unfortunate. Axis heights can be done in either pixels or + * percentages. If you use percentages, it's percentage of the plot height + * which includes the y-axis labels and the legend. These heights are + * variable, so setting a 20% height on the swimlane axis means the axis + * height can actually change depending on external factors. When trying to + * accurately position lanes, this variation can mess up pixel-perfect + * spacing. + * + * If you use pixels you can get more exact, but since there's no way to + * determine the available height for the chart (plot area minus y-axis + * labels minus legend), you're left in the dark on how high to make your + * "rest of the space" axis. There's also no way to set the height of one + * axis and have the other axis take the remaining space. + * + * So, as a workaround, setting the swimlane axis to a fixed height and + * having it sit on top of a full height axis works well enough. Adding a + * bit of padding to the primary axis prevents those values from flowing on + * top. It's not perfect because you get the main axis all the way up the + * side but it's not terrible. + */ + y_max += ((sensor_count > 8) ? 20 : 10); + + var tick_positions = []; + var tick_interval = (beestat.setting('temperature_unit') === '°F') ? 10 : 5; + var current_tick_position = + Math.floor(y_min / tick_interval) * tick_interval; + while (current_tick_position <= y_max) { + tick_positions.push(current_tick_position); + current_tick_position += tick_interval; + } + + return [ + // Temperature + { + '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'); + } + }, + 'tickPositions': tick_positions + }, + + // Swimlanes + { + 'height': 100, + 'top': 15, + 'min': 0, + 'max': 100, + 'reversed': true, + 'gridLineWidth': 0, + 'title': {'text': null}, + 'labels': {'enabled': false} + } + ]; +}; + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.runtime_sensor_detail.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var sections = []; + var group = []; + + var values = {}; + this.points.forEach(function(point) { + values[point.series.name] = point.y; + }); + + this.points.forEach(function(point) { + var label; + var value; + var color; + + if (point.series.name.includes('temperature') === true) { + label = self.data_.metadata.series[point.series.name].name; + color = point.series.color; + value = beestat.temperature({ + 'temperature': values[point.series.name], + 'convert': false, + 'units': true + }); + + var occupancy_key = point.series.name.replace('temperature', 'occupancy'); + if (values[occupancy_key] !== undefined && values[occupancy_key] !== null) { + value += ' ●'; + } + } else { + return; + } + + group.push({ + 'label': label, + 'value': value, + 'color': color + }); + }); + + if (group.length === 0) { + group.push({ + 'label': 'No data', + 'value': '', + 'color': beestat.style.color.gray.base + }); + } + + sections.push(group); + + var title = this.x.format('ddd, MMM D @ h:mma'); + + return self.tooltip_formatter_helper_( + title, + sections + ); + }; +}; + +/** + * Get properties of swimlane series. + * + * @param {number} count The number of swimlanes present. + * @param {number} i Which swimlane this is. + * + * @return {Object} The swimlane line width and y position. + */ +beestat.component.chart.runtime_sensor_detail.get_swimlane_properties = function(count, i) { + var height = 50; + var max_line_width = 16; + + // Spacing. + var spacing = 4; + + // Base line width is a percentage height of the container. + var line_width = Math.round(height / count); + + // Cap to a max line width. + line_width = Math.min(line_width, max_line_width); + + // Set y, then shift it up slightly because the width expands out from the center. + var y = (line_width * i); + y += Math.round((line_width / 2)); + + // Make the lines slightly less tall to create space between them. + line_width -= spacing; + + return { + 'line_width': line_width, + 'y': y + }; +}; diff --git a/js/component/chart/runtime_detail.js b/js/component/chart/runtime_thermostat_detail.js similarity index 94% rename from js/component/chart/runtime_detail.js rename to js/component/chart/runtime_thermostat_detail.js index 309322d..9f4e7b1 100644 --- a/js/component/chart/runtime_detail.js +++ b/js/component/chart/runtime_thermostat_detail.js @@ -1,21 +1,21 @@ /** - * Runtime thermostat summary chart. + * Runtime thermostat detail chart. * * @param {object} data The chart data. */ -beestat.component.chart.runtime_detail = function(data) { +beestat.component.chart.runtime_thermostat_detail = function(data) { this.data_ = data; beestat.component.chart.apply(this, arguments); }; -beestat.extend(beestat.component.chart.runtime_detail, beestat.component.chart); +beestat.extend(beestat.component.chart.runtime_thermostat_detail, beestat.component.chart); /** * Override for get_options_xAxis_labels_formatter_. * * @return {Function} xAxis labels formatter. */ -beestat.component.chart.runtime_detail.prototype.get_options_xAxis_labels_formatter_ = function() { +beestat.component.chart.runtime_thermostat_detail.prototype.get_options_xAxis_labels_formatter_ = function() { var current_day; var current_hour; @@ -43,7 +43,7 @@ beestat.component.chart.runtime_detail.prototype.get_options_xAxis_labels_format * * @return {Array} All of the series to display on the chart. */ -beestat.component.chart.runtime_detail.prototype.get_options_series_ = function() { +beestat.component.chart.runtime_thermostat_detail.prototype.get_options_series_ = function() { var self = this; var series = []; @@ -170,7 +170,7 @@ beestat.component.chart.runtime_detail.prototype.get_options_series_ = function( * * @return {Array} The y-axis options. */ -beestat.component.chart.runtime_detail.prototype.get_options_yAxis_ = function() { +beestat.component.chart.runtime_thermostat_detail.prototype.get_options_yAxis_ = function() { /** * Highcharts doesn't seem to respect axis behavior well so just overriding * it completely here. @@ -245,7 +245,7 @@ beestat.component.chart.runtime_detail.prototype.get_options_yAxis_ = function() * * @return {Function} The tooltip formatter. */ -beestat.component.chart.runtime_detail.prototype.get_options_tooltip_formatter_ = function() { +beestat.component.chart.runtime_thermostat_detail.prototype.get_options_tooltip_formatter_ = function() { var self = this; return function() { diff --git a/js/component/header.js b/js/component/header.js index 9f194f5..61744d0 100644 --- a/js/component/header.js +++ b/js/component/header.js @@ -22,18 +22,39 @@ beestat.component.header.prototype.decorate_ = function(parent) { var self = this; var pages; - pages = [ - { - 'layer': 'dashboard', - 'text': 'Dashboard', - 'icon': 'gauge' - }, - { - 'layer': 'home_comparisons', - 'text': 'Home Comparisons', - 'icon': 'home' - } - ]; + + if (false && beestat.has_early_access() === true) { + pages = [ + { + 'layer': 'dashboard', + 'text': 'Dashboard', + 'icon': 'tablet_dashboard' + }, + { + 'layer': 'sensors', + 'text': 'Sensors', + 'icon': 'signal_variant' + }, + { + 'layer': 'home_comparisons', + 'text': 'Comparisons', + 'icon': 'home_group' + } + ]; + } else { + pages = [ + { + 'layer': 'dashboard', + 'text': 'Dashboard', + 'icon': 'tablet_dashboard' + }, + { + 'layer': 'home_comparisons', + 'text': 'Home Comparisons', + 'icon': 'home_group' + } + ]; + } var gutter = beestat.style.size.gutter; @@ -77,7 +98,7 @@ beestat.component.header.prototype.decorate_ = function(parent) { .set_icon(page.icon) .set_text_color(beestat.style.color.bluegray.dark); - if (beestat.width > 500) { + if (beestat.width > 650) { button.set_text(page.text); } diff --git a/js/component/modal/runtime_sensor_detail_custom.js b/js/component/modal/runtime_sensor_detail_custom.js new file mode 100644 index 0000000..e69de29 diff --git a/js/component/modal/runtime_detail_custom.js b/js/component/modal/runtime_thermostat_detail_custom.js similarity index 63% rename from js/component/modal/runtime_detail_custom.js rename to js/component/modal/runtime_thermostat_detail_custom.js index 9a677a8..0203c4b 100644 --- a/js/component/modal/runtime_detail_custom.js +++ b/js/component/modal/runtime_thermostat_detail_custom.js @@ -1,12 +1,12 @@ /** * Custom date range for the Runtime Detail chart. */ -beestat.component.modal.runtime_detail_custom = function() { +beestat.component.modal.runtime_thermostat_detail_custom = function() { beestat.component.modal.apply(this, arguments); - this.state_.runtime_detail_range_type = beestat.setting('runtime_detail_range_type'); - this.state_.runtime_detail_range_dynamic = beestat.setting('runtime_detail_range_dynamic'); - this.state_.runtime_detail_range_static_begin = beestat.setting('runtime_detail_range_static_begin'); - this.state_.runtime_detail_range_static_end = beestat.setting('runtime_detail_range_static_end'); + this.state_.runtime_thermostat_detail_range_type = beestat.setting('runtime_thermostat_detail_range_type'); + this.state_.runtime_thermostat_detail_range_dynamic = beestat.setting('runtime_thermostat_detail_range_dynamic'); + this.state_.runtime_thermostat_detail_range_static_begin = beestat.setting('runtime_thermostat_detail_range_static_begin'); + this.state_.runtime_thermostat_detail_range_static_end = beestat.setting('runtime_thermostat_detail_range_static_end'); this.state_.error = { 'max_range': false, 'invalid_range_begin': false, @@ -14,19 +14,19 @@ beestat.component.modal.runtime_detail_custom = function() { 'out_of_sync_range': false }; }; -beestat.extend(beestat.component.modal.runtime_detail_custom, beestat.component.modal); +beestat.extend(beestat.component.modal.runtime_thermostat_detail_custom, beestat.component.modal); /** * Decorate. * * @param {rocket.Elements} parent */ -beestat.component.modal.runtime_detail_custom.prototype.decorate_contents_ = function(parent) { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_contents_ = function(parent) { parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Runtime Detail chart.')); this.decorate_range_type_(parent); - if (this.state_.runtime_detail_range_type === 'dynamic') { + if (this.state_.runtime_thermostat_detail_range_type === 'dynamic') { this.decorate_range_dynamic_(parent); } else { this.decorate_range_static_(parent); @@ -40,7 +40,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_contents_ = fun * * @param {rocket.Elements} parent */ -beestat.component.modal.runtime_detail_custom.prototype.decorate_range_type_ = function(parent) { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_range_type_ = function(parent) { var self = this; var button_group = new beestat.component.button_group(); @@ -49,13 +49,13 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_type_ = f .set_background_hover_color(beestat.style.color.lightblue.base) .set_text_color('#fff') .set_background_color( - this.state_.runtime_detail_range_type === 'dynamic' + this.state_.runtime_thermostat_detail_range_type === 'dynamic' ? beestat.style.color.lightblue.base : beestat.style.color.bluegray.base ) .set_text('Dynamic') .addEventListener('click', function() { - self.state_.runtime_detail_range_type = 'dynamic'; + self.state_.runtime_thermostat_detail_range_type = 'dynamic'; self.rerender(); })); @@ -63,13 +63,13 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_type_ = f .set_background_hover_color(beestat.style.color.lightblue.base) .set_text_color('#fff') .set_background_color( - this.state_.runtime_detail_range_type === 'static' + this.state_.runtime_thermostat_detail_range_type === 'static' ? beestat.style.color.lightblue.base : beestat.style.color.bluegray.base ) .set_text('Static') .addEventListener('click', function() { - self.state_.runtime_detail_range_type = 'static'; + self.state_.runtime_thermostat_detail_range_type = 'static'; self.rerender(); })); @@ -86,11 +86,11 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_type_ = f * * @param {rocket.Elements} parent */ -beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = function(parent) { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_range_static_ = function(parent) { var self = this; - var runtime_detail_static_range_begin; - var runtime_detail_static_range_end; + var runtime_thermostat_detail_static_range_begin; + var runtime_thermostat_detail_static_range_end; /** * Check whether or not a value is outside of where data is synced. @@ -104,13 +104,13 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = var max = moment(thermostat.sync_end); var begin = moment.min( - moment(runtime_detail_static_range_begin.get_value()), - moment(runtime_detail_static_range_end.get_value()) + moment(runtime_thermostat_detail_static_range_begin.get_value()), + moment(runtime_thermostat_detail_static_range_end.get_value()) ); var end = moment.max( - moment(runtime_detail_static_range_begin.get_value() + ' 00:00:00'), - moment(runtime_detail_static_range_end.get_value() + ' 23:59:59') + moment(runtime_thermostat_detail_static_range_begin.get_value() + ' 00:00:00'), + moment(runtime_thermostat_detail_static_range_end.get_value() + ' 23:59:59') ); if ( @@ -123,7 +123,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = } }; - runtime_detail_static_range_begin = new beestat.component.input.text() + runtime_thermostat_detail_static_range_begin = new beestat.component.input.text() .set_style({ 'width': 110, 'text-align': 'center', @@ -133,16 +133,16 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = 'maxlength': 10 }) .set_icon('calendar') - .set_value(this.state_.runtime_detail_range_static_begin); + .set_value(this.state_.runtime_thermostat_detail_range_static_begin); - runtime_detail_static_range_begin.addEventListener('blur', function() { + runtime_thermostat_detail_static_range_begin.addEventListener('blur', function() { var m = moment(this.get_value()); if (m.isValid() === true) { self.state_.error.invalid_range_begin = false; var value = m.format('M/D/YYYY'); - var diff = Math.abs(m.diff(moment(runtime_detail_static_range_end.get_value()), 'day')) + 1; + var diff = Math.abs(m.diff(moment(runtime_thermostat_detail_static_range_end.get_value()), 'day')) + 1; if (diff > 30) { self.state_.error.max_range = true; } else { @@ -151,16 +151,16 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = check_out_of_sync_range(); - self.state_.runtime_detail_range_static_begin = value; + self.state_.runtime_thermostat_detail_range_static_begin = value; self.rerender(); } else { - self.state_.runtime_detail_range_static_begin = this.get_value(); + self.state_.runtime_thermostat_detail_range_static_begin = this.get_value(); self.state_.error.invalid_range_begin = true; self.rerender(); } }); - runtime_detail_static_range_end = new beestat.component.input.text() + runtime_thermostat_detail_static_range_end = new beestat.component.input.text() .set_style({ 'width': 110, 'text-align': 'center', @@ -170,16 +170,16 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = 'maxlength': 10 }) .set_icon('calendar') - .set_value(this.state_.runtime_detail_range_static_end); + .set_value(this.state_.runtime_thermostat_detail_range_static_end); - runtime_detail_static_range_end.addEventListener('blur', function() { + runtime_thermostat_detail_static_range_end.addEventListener('blur', function() { var m = moment(this.get_value()); if (m.isValid() === true) { self.state_.error.invalid_range_end = false; var value = m.format('M/D/YYYY'); - var diff = Math.abs(m.diff(moment(runtime_detail_static_range_begin.get_value()), 'day')) + 1; + var diff = Math.abs(m.diff(moment(runtime_thermostat_detail_static_range_begin.get_value()), 'day')) + 1; if (diff > 30) { self.state_.error.max_range = true; } else { @@ -188,10 +188,10 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = check_out_of_sync_range(); - self.state_.runtime_detail_range_static_end = value; + self.state_.runtime_thermostat_detail_range_static_end = value; self.rerender(); } else { - self.state_.runtime_detail_range_static_end = this.get_value(); + self.state_.runtime_thermostat_detail_range_static_end = this.get_value(); self.state_.error.invalid_range_end = true; self.rerender(); } @@ -205,7 +205,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = row.appendChild(column); span = $.createElement('span').style('display', 'inline-block'); - runtime_detail_static_range_begin.render(span); + runtime_thermostat_detail_static_range_begin.render(span); column.appendChild(span); span = $.createElement('span') @@ -218,7 +218,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = column.appendChild(span); span = $.createElement('span').style('display', 'inline-block'); - runtime_detail_static_range_end.render(span); + runtime_thermostat_detail_static_range_end.render(span); column.appendChild(span); }; @@ -227,10 +227,10 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = * * @param {rocket.Elements} parent */ -beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ = function(parent) { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_range_dynamic_ = function(parent) { var self = this; - var runtime_detail_range_dynamic = new beestat.component.input.text() + var runtime_thermostat_detail_range_dynamic = new beestat.component.input.text() .set_style({ 'width': 75, 'text-align': 'center', @@ -240,9 +240,9 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ 'maxlength': 2 }) .set_icon('pound') - .set_value(beestat.setting('runtime_detail_range_dynamic')); + .set_value(beestat.setting('runtime_thermostat_detail_range_dynamic')); - runtime_detail_range_dynamic.addEventListener('blur', function() { + runtime_thermostat_detail_range_dynamic.addEventListener('blur', function() { var value = parseInt(this.get_value(), 10); if (isNaN(value) === true || value === 0) { value = 1; @@ -250,7 +250,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ value = 30; } this.set_value(value); - self.state_.runtime_detail_range_dynamic = value; + self.state_.runtime_thermostat_detail_range_dynamic = value; }); var span; @@ -261,7 +261,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ row.appendChild(column); span = $.createElement('span').style('display', 'inline-block'); - runtime_detail_range_dynamic.render(span); + runtime_thermostat_detail_range_dynamic.render(span); column.appendChild(span); span = $.createElement('span') @@ -278,7 +278,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ * * @param {rocket.Elements} parent */ -beestat.component.modal.runtime_detail_custom.prototype.decorate_error_ = function(parent) { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_error_ = function(parent) { var div = $.createElement('div').style('color', beestat.style.color.red.base); if (this.state_.error.max_range === true) { div.appendChild($.createElement('div').innerText('Max range is 30 days.')); @@ -300,7 +300,7 @@ beestat.component.modal.runtime_detail_custom.prototype.decorate_error_ = functi * * @return {string} Title */ -beestat.component.modal.runtime_detail_custom.prototype.get_title_ = function() { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.get_title_ = function() { return 'Runtime Detail - Custom Range'; }; @@ -309,7 +309,7 @@ beestat.component.modal.runtime_detail_custom.prototype.get_title_ = function() * * @return {[beestat.component.button]} The buttons. */ -beestat.component.modal.runtime_detail_custom.prototype.get_buttons_ = function() { +beestat.component.modal.runtime_thermostat_detail_custom.prototype.get_buttons_ = function() { var self = this; var cancel = new beestat.component.button() @@ -344,19 +344,19 @@ beestat.component.modal.runtime_detail_custom.prototype.get_buttons_ = function( .set_background_hover_color() .removeEventListener('click'); - if (moment(self.state_.runtime_detail_range_static_begin).isAfter(moment(self.state_.runtime_detail_range_static_end)) === true) { - var temp = self.state_.runtime_detail_range_static_begin; - self.state_.runtime_detail_range_static_begin = self.state_.runtime_detail_range_static_end; - self.state_.runtime_detail_range_static_end = temp; + if (moment(self.state_.runtime_thermostat_detail_range_static_begin).isAfter(moment(self.state_.runtime_thermostat_detail_range_static_end)) === true) { + var temp = self.state_.runtime_thermostat_detail_range_static_begin; + self.state_.runtime_thermostat_detail_range_static_begin = self.state_.runtime_thermostat_detail_range_static_end; + self.state_.runtime_thermostat_detail_range_static_end = temp; } beestat.cache.delete('runtime_thermostat'); beestat.setting( { - 'runtime_detail_range_type': self.state_.runtime_detail_range_type, - 'runtime_detail_range_dynamic': self.state_.runtime_detail_range_dynamic, - 'runtime_detail_range_static_begin': self.state_.runtime_detail_range_static_begin, - 'runtime_detail_range_static_end': self.state_.runtime_detail_range_static_end + 'runtime_thermostat_detail_range_type': self.state_.runtime_thermostat_detail_range_type, + 'runtime_thermostat_detail_range_dynamic': self.state_.runtime_thermostat_detail_range_dynamic, + 'runtime_thermostat_detail_range_static_begin': self.state_.runtime_thermostat_detail_range_static_begin, + 'runtime_thermostat_detail_range_static_end': self.state_.runtime_thermostat_detail_range_static_end }, undefined, function() { diff --git a/js/js.php b/js/js.php index d5b6172..f699086 100755 --- a/js/js.php +++ b/js/js.php @@ -37,6 +37,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; // Component echo '' . PHP_EOL; @@ -50,7 +51,8 @@ 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; @@ -61,7 +63,8 @@ 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; @@ -78,7 +81,8 @@ 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; diff --git a/js/layer/dashboard.js b/js/layer/dashboard.js index d73920f..6308635 100644 --- a/js/layer/dashboard.js +++ b/js/layer/dashboard.js @@ -79,7 +79,7 @@ beestat.layer.dashboard.prototype.decorate_ = function(parent) { cards.push([ { - 'card': new beestat.component.card.runtime_detail( + 'card': new beestat.component.card.runtime_thermostat_detail( beestat.setting('thermostat_id') ), 'size': 12 diff --git a/js/layer/load.js b/js/layer/load.js index 591bdd4..66ae94f 100644 --- a/js/layer/load.js +++ b/js/layer/load.js @@ -204,7 +204,7 @@ beestat.layer.load.prototype.decorate_ = function(parent) { */ new beestat.api() .add_call( - 'runtime_thermostat', + 'runtime', 'sync', { 'thermostat_id': thermostat.thermostat_id diff --git a/js/layer/sensors.js b/js/layer/sensors.js new file mode 100644 index 0000000..4bde099 --- /dev/null +++ b/js/layer/sensors.js @@ -0,0 +1,59 @@ +/** + * Sensors layer. + */ +beestat.layer.sensors = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.sensors, beestat.layer); + +beestat.layer.sensors.prototype.decorate_ = function(parent) { + /* + * Set the overflow on the body so the scrollbar is always present so + * highcharts graphs render properly. + */ + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + (new beestat.component.header('sensors')).render(parent); + + // All the cards + var cards = []; + + if (window.is_demo === true) { + cards.push([ + { + 'card': new beestat.component.card.demo(), + 'size': 12 + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.sensors(), + 'size': 12 + } + ]); + + cards.push([ + { + 'card': new beestat.component.card.runtime_sensor_detail( + beestat.setting('thermostat_id') + ), + 'size': 12 + } + ]); + + // Footer + cards.push([ + { + 'card': new beestat.component.card.footer(), + 'size': 12 + } + ]); + + (new beestat.component.layout(cards)).render(parent); +};