From f5ff29d45ebea455974f4132d4c7acf2faae75ce Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Wed, 6 Nov 2019 23:04:10 -0500 Subject: [PATCH] New Download Data button for #180 Adds a download data button with a somewhat customization date range selector. --- api/runtime_thermostat.php | 177 ++++++++++++++++++++++++-- js/component/header.js | 7 ++ js/component/modal/download_data.js | 187 ++++++++++++++++++++++++++++ js/js.php | 1 + 4 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 js/component/modal/download_data.js diff --git a/api/runtime_thermostat.php b/api/runtime_thermostat.php index c4f7e63..4cdd3e6 100755 --- a/api/runtime_thermostat.php +++ b/api/runtime_thermostat.php @@ -10,7 +10,8 @@ class runtime_thermostat extends cora\crud { public static $exposed = [ 'private' => [ 'read', - 'sync' + 'sync', + 'download' ], 'public' => [] ]; @@ -82,6 +83,7 @@ class runtime_thermostat extends cora\crud { ) ); } else { + $this->user_lock($thermostat_id); $thermostat_ids = [$thermostat_id]; } @@ -267,6 +269,8 @@ class runtime_thermostat extends cora\crud { * @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']); @@ -333,7 +337,6 @@ class runtime_thermostat extends cora\crud { 'Y-m-d H:i:s', strtotime($columns_begin['date'] . ' ' . $columns_begin['time'] . ' -1 hour') ), - // $columns_begin['date'] . ' ' . $columns_begin['time'], $thermostat['time_zone'] ), $this->get_utc_datetime( @@ -526,8 +529,26 @@ class runtime_thermostat extends cora\crud { */ 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(new DateTimeZone('UTC')); + $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'); } @@ -544,7 +565,7 @@ class runtime_thermostat extends cora\crud { * @return array */ public function read($attributes = [], $columns = []) { - $thermostats = $this->api('thermostat', 'read_id'); + $this->user_lock($attributes['thermostat_id']); // Check for exceptions. if (isset($attributes['thermostat_id']) === false) { @@ -555,10 +576,6 @@ class runtime_thermostat extends cora\crud { throw new \Exception('Missing required attribute: timestamp.', 10202); } - if (isset($thermostats[$attributes['thermostat_id']]) === false) { - throw new \Exception('Invalid thermostat_id.', 10203); - } - if ( is_array($attributes['timestamp']) === true && in_array($attributes['timestamp']['operator'], ['>', '>=', '<', '<=']) === true && @@ -571,7 +588,7 @@ class runtime_thermostat extends cora\crud { } } - $thermostat = $thermostats[$attributes['thermostat_id']]; + $thermostat = $this->api('thermostat', 'get', $attributes['thermostat_id']); $max_range = 2592000; // 30 days if ( ( @@ -659,4 +676,146 @@ 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); + + header('Content-type: text/csv'); + header('Content-Length: ' . $bytes); + header('Content-Disposition: attachment; filename="Beestat Export - ' . $ecobee_thermostat['identifier'] . '.csv"'); + header('Pragma: no-cache'); + header('Expires: 0'); + + die(); + } + + /** + * 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/js/component/header.js b/js/component/header.js index 4486ec3..06c9158 100644 --- a/js/component/header.js +++ b/js/component/header.js @@ -146,6 +146,13 @@ beestat.component.header.prototype.decorate_ = function(parent) { } menu.add_menu_item(announcements_menu_item); + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Download Data') + .set_icon('download') + .set_callback(function() { + (new beestat.component.modal.download_data()).render(); + })); + menu.add_menu_item(new beestat.component.menu_item() .set_text('Log Out') .set_icon('exit_to_app') diff --git a/js/component/modal/download_data.js b/js/component/modal/download_data.js new file mode 100644 index 0000000..3476d3d --- /dev/null +++ b/js/component/modal/download_data.js @@ -0,0 +1,187 @@ +/** + * Download data modal. + */ +beestat.component.modal.download_data = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.download_data, beestat.component.modal); + +beestat.component.modal.download_data.prototype.decorate_contents_ = function(parent) { + var self = this; + + parent.appendChild($.createElement('p').innerHTML('Choose a custom download range.')); + + self.state_.download_data_time_count = 1; + + // Time count + var time_count = new beestat.component.input.text() + .set_style({ + 'width': 75, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('pound') + .set_value(self.state_.download_data_time_count); + + time_count.addEventListener('blur', function() { + self.state_.download_data_time_count = + parseInt(this.get_value(), 10) || 1; + }); + + // Button groups + var options = { + 'download_data_time_period': [ + 'day', + 'week', + 'month', + 'year', + 'all' + ] + }; + + var button_groups = {}; + + this.selected_buttons_ = {}; + for (let key in options) { + let current_type = 'month'; + + let button_group = new beestat.component.button_group(); + options[key].forEach(function(value) { + let text = value.replace('download_data_', '') + .charAt(0) + .toUpperCase() + + value.slice(1) + + ( + ( + key === 'download_data_time_period' && + value !== 'all' + ) ? 's' : '' + ); + + let button = new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_text(text) + .addEventListener('click', function() { + if (key === 'download_data_time_period') { + if (value === 'all') { + time_count.set_value('∞').disable(); + } else if (time_count.get_value() === '∞') { + time_count + .set_value(self.state_.download_data_time_count || '1') + .enable(); + time_count.dispatchEvent('blur'); + } + } + + if (current_type !== value) { + this.set_background_color(beestat.style.color.lightblue.base); + if (self.selected_buttons_[key] !== undefined) { + self.selected_buttons_[key] + .set_background_color(beestat.style.color.bluegray.base); + } + self.selected_buttons_[key] = this; + self.state_[key] = value; + current_type = value; + } + }); + + if (current_type === value) { + if ( + key === 'download_data_time_period' && + value === 'all' + ) { + time_count.set_value('∞').disable(); + } + + button.set_background_color(beestat.style.color.lightblue.base); + self.state_[key] = value; + self.selected_buttons_[key] = button; + } else { + button.set_background_color(beestat.style.color.bluegray.base); + } + + button_group.add_button(button); + }); + button_groups[key] = button_group; + } + + // Display it all + var row; + var column; + + (new beestat.component.title('Time Period')).render(parent); + row = $.createElement('div').addClass('row'); + parent.appendChild(row); + column = $.createElement('div').addClass(['column column_2']); + row.appendChild(column); + time_count.render(column); + column = $.createElement('div').addClass(['column column_10']); + row.appendChild(column); + button_groups.download_data_time_period.render(column); +}; + +/** + * Get title. + * + * @return {string} Title + */ +beestat.component.modal.download_data.prototype.get_title_ = function() { + return 'Download Data'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.download_data.prototype.get_buttons_ = function() { + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Download Data') + .addEventListener('click', function() { + var download_begin; + var download_end = null; + if (self.state_.download_data_time_period === 'all') { + download_begin = null; + } else { + download_begin = moment().utc() + .subtract( + self.state_.download_data_time_count, + self.state_.download_data_time_period + ) + .format('YYYY-MM-DD HH:mm:ss'); + } + + var download_arguments = { + 'thermostat_id': beestat.setting('thermostat_id'), + 'download_begin': download_begin, + 'download_end': download_end + }; + + window.location.href = '/api/?resource=runtime_thermostat&method=download&arguments=' + JSON.stringify(download_arguments) + '&api_key=' + beestat.api.api_key; + + self.dispose(); + }); + + return [ + cancel, + save + ]; +}; diff --git a/js/js.php b/js/js.php index 939b260..98e5371 100755 --- a/js/js.php +++ b/js/js.php @@ -74,6 +74,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL;