diff --git a/api/runtime.php b/api/runtime.php index 2ed1015..cce8ff3 100644 --- a/api/runtime.php +++ b/api/runtime.php @@ -728,10 +728,12 @@ class runtime extends cora\api { * 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. + * @param string $download_begin The timestamp to begin the download. If a + * time zone is not specified, UTC is assumed. + * @param string $download_end The timestamp to end the download. If a time + * zone is not specified, UTC is assumed. */ - public function download($thermostat_id, $download_begin = null, $download_end = null) { + public function download($thermostat_id, $download_begin, $download_end) { set_time_limit(120); $this->user_lock($thermostat_id); @@ -743,17 +745,25 @@ class runtime extends cora\api { $thermostat['ecobee_thermostat_id'] ); - if($download_begin === null) { - $download_begin = strtotime($thermostat['first_connected']); - } else { - $download_begin = strtotime($download_begin); + // Allow for inverted arguments. + if (strtotime($download_end) < strtotime($download_begin)) { + $temp = $download_begin; + $download_begin = $download_end; + $download_end = $temp; } - if($download_end === null) { - $download_end = time(); - } else { - $download_end = strtotime($download_end); - } + // Clamp + $download_begin = strtotime($download_begin); + $download_begin = max(strtotime($thermostat['first_connected']), $download_begin); + $download_begin = min(time(), $download_begin); + + $download_end = strtotime($download_end); + $download_end = max(strtotime($thermostat['first_connected']), $download_end); + $download_end = min(time(), $download_end); + + // Round begin/end down to the next 5 minutes. + $download_begin = floor($download_begin / 300) * 300; + $download_end = floor($download_end / 300) * 300; $chunk_begin = $download_begin; $chunk_end = $download_begin; @@ -771,7 +781,7 @@ class runtime extends cora\api { [ 'thermostat_id' => $thermostat_id, 'timestamp' => [ - 'value' => [date('Y-m-d H:i:s', $chunk_begin), date('Y-m-d H:i:s', $chunk_end)] , + 'value' => [date('Y-m-d H:i:s', $chunk_begin), date('Y-m-d H:i:s', $chunk_end)], 'operator' => 'between' ] ], @@ -808,19 +818,22 @@ class runtime extends cora\api { $needs_header = false; } + $runtime_thermostats_by_timestamp = []; 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; + if( + isset($thermostat['setting']['temperature_unit']) === true && + $thermostat['setting']['temperature_unit'] === '°C' + ) { + $runtime_thermostat[$key] = + round(($runtime_thermostat[$key] - 32) * (5 / 9), 1); + } } } @@ -833,7 +846,30 @@ class runtime extends cora\api { $runtime_thermostat['climate_runtime_thermostat_text_id'] = $runtime_thermostat_texts[$runtime_thermostat['climate_runtime_thermostat_text_id']]['value']; } - $bytes += fputcsv($output, $runtime_thermostat); + $strtotime = strtotime($runtime_thermostat['timestamp']); + $runtime_thermostats_by_timestamp[$strtotime] = $runtime_thermostat; + + // Now remove it since it's not used. + unset($runtime_thermostats_by_timestamp[$strtotime]['timestamp']); + } + + $current_timestamp = $chunk_begin; + while($current_timestamp <= $chunk_end) { + $local_datetime = $this->get_local_datetime( + date('Y-m-d H:i:s', $current_timestamp), + $thermostat['time_zone'] + ); + + if(isset($runtime_thermostats_by_timestamp[$current_timestamp]) === true) { + $csv_row = array_merge( + [$local_datetime], + $runtime_thermostats_by_timestamp[$current_timestamp] + ); + } else { + $csv_row = [$local_datetime]; + } + $bytes += fputcsv($output, $csv_row); + $current_timestamp += 300; } $chunk_begin = $chunk_end; diff --git a/js/component/button.js b/js/component/button.js index eb1adcb..ad5382b 100644 --- a/js/component/button.js +++ b/js/component/button.js @@ -100,6 +100,10 @@ beestat.component.button.prototype.decorate_ = function(parent) { this.button_.addEventListener('click', function() { self.dispatchEvent('click'); }); + + this.button_.addEventListener('mousedown', function() { + self.dispatchEvent('mousedown'); + }); }; /** diff --git a/js/component/input/text.js b/js/component/input/text.js index 37823b5..9f189bb 100644 --- a/js/component/input/text.js +++ b/js/component/input/text.js @@ -2,7 +2,24 @@ * Input parent class. */ beestat.component.input.text = function() { + var self = this; + this.input_ = $.createElement('input'); + + // Add these up top so they don't get re-added on rerender. + this.input_.addEventListener('focus', function() { + self.input_.style({ + 'background': beestat.style.color.bluegray.dark + }); + }); + + this.input_.addEventListener('blur', function() { + self.dispatchEvent('blur'); + self.input_.style({ + 'background': beestat.style.color.bluegray.light + }); + }); + beestat.component.apply(this, arguments); }; beestat.extend(beestat.component.input.text, beestat.component.input); @@ -15,8 +32,6 @@ beestat.component.input.text.prototype.rerender_on_breakpoint_ = false; * @param {rocket.Elements} parent */ beestat.component.input.text.prototype.decorate_ = function(parent) { - var self = this; - this.input_ .setAttribute('type', 'text') .style({ @@ -56,19 +71,6 @@ beestat.component.input.text.prototype.decorate_ = function(parent) { (new beestat.component.icon(this.icon_).set_size(16).set_color('#fff')).render(icon_container); } - this.input_.addEventListener('focus', function() { - self.input_.style({ - 'background': beestat.style.color.bluegray.dark - }); - }); - - this.input_.addEventListener('blur', function() { - self.dispatchEvent('blur'); - self.input_.style({ - 'background': beestat.style.color.bluegray.light - }); - }); - if (this.value_ !== undefined) { this.input_.value(this.value_); } diff --git a/js/component/modal/download_data.js b/js/component/modal/download_data.js index 3846bf2..8ca05a6 100644 --- a/js/component/modal/download_data.js +++ b/js/component/modal/download_data.js @@ -3,125 +3,212 @@ */ beestat.component.modal.download_data = function() { beestat.component.modal.apply(this, arguments); + this.state_.range_begin = moment().hour(0) + .minute(0) + .second(0) + .millisecond(0); + this.state_.range_end = this.state_.range_begin.clone(); }; beestat.extend(beestat.component.modal.download_data, beestat.component.modal); +/** + * Decorate. + * + * @param {rocket.Elements} parent + */ beestat.component.modal.download_data.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Beestat stores, at a minimum, the past year of raw thermostat logs. Select a date range to download.')); + this.decorate_range_(parent); + this.decorate_presets_(parent); + this.decorate_error_(parent); + + // Fire off this event once to get everything updated. + this.dispatchEvent('range_change'); +}; + +/** + * Decorate range inputs. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.download_data.prototype.decorate_range_ = function(parent) { var self = this; - parent.appendChild($.createElement('p').innerHTML('Choose a custom download range.')); + (new beestat.component.title('Date Range')).render(parent); - self.state_.download_data_time_count = 1; - - // Time count - var time_count = new beestat.component.input.text() + var range_begin = new beestat.component.input.text() .set_style({ - 'width': 75, + 'width': 110, '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); + .set_icon('calendar') + .set_value(this.state_.range_begin.format('M/D/YYYY')); - time_count.addEventListener('blur', function() { - self.state_.download_data_time_count = - parseInt(this.get_value(), 10) || 1; + range_begin.addEventListener('blur', function() { + self.state_.range_begin = moment(this.get_value()); + self.dispatchEvent('range_change'); }); - // Button groups - var options = { - 'download_data_time_period': [ - 'day', - 'week', - 'month', - 'year', - 'all' - ] - }; + var range_end = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.range_end.format('M/D/YYYY')); - var button_groups = {}; + range_end.addEventListener('blur', function() { + self.state_.range_end = moment(this.get_value()); + self.dispatchEvent('range_change'); + }); - this.selected_buttons_ = {}; - for (let key in options) { - let current_type = 'month'; + // Update the inputs if the range changes. + this.addEventListener('range_change', function() { + if (self.state_.range_begin.isValid() === true) { + range_begin.set_value(self.state_.range_begin.format('M/D/YYYY')); + } - 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' : '' - ); + if (self.state_.range_end.isValid() === true) { + range_end.set_value(self.state_.range_end.format('M/D/YYYY')); + } + }); - 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'); - } - } + var span; - 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'); + var row = $.createElement('div').addClass('row'); parent.appendChild(row); - column = $.createElement('div').addClass(['column column_2']); + var column = $.createElement('div').addClass(['column column_12']); row.appendChild(column); - time_count.render(column); - column = $.createElement('div').addClass(['column column_10']); + + span = $.createElement('span').style('display', 'inline-block'); + range_begin.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter, + 'margin-right': beestat.style.size.gutter + }) + .innerText('to'); + column.appendChild(span); + + span = $.createElement('span').style('display', 'inline-block'); + range_end.render(span); + column.appendChild(span); +}; + +/** + * Decorate preset options. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.download_data.prototype.decorate_presets_ = function(parent) { + var self = this; + + (new beestat.component.title('Presets')).render(parent); + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); row.appendChild(column); - button_groups.download_data_time_period.render(column); + + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var now = moment().hour(0) + .minute(0) + .second(0) + .millisecond(0); + + var presets = [ + { + 'label': 'Today', + 'range_begin': now.clone(), + 'range_end': now.clone(), + 'button': new beestat.component.button() + }, + { + 'label': 'This Week', + 'range_begin': now.clone().startOf('week'), + 'range_end': now.clone(), + 'button': new beestat.component.button() + }, + { + 'label': 'This Month', + 'range_begin': now.clone().startOf('month'), + 'range_end': now.clone(), + 'button': new beestat.component.button() + }, + { + 'label': 'All Time', + 'range_begin': moment.max(moment(thermostat.first_connected), now.clone().subtract(1, 'year')), + 'range_end': now.clone(), + 'button': new beestat.component.button() + } + ]; + + var button_group = new beestat.component.button_group(); + presets.forEach(function(preset) { + preset.button + .set_background_color(beestat.style.color.bluegray.base) + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_text(preset.label) + .addEventListener('mousedown', function() { + self.state_.range_begin = preset.range_begin; + self.state_.range_end = preset.range_end; + self.dispatchEvent('range_change'); + }); + button_group.add_button(preset.button); + }); + + // Highlight the preset if the current date range matches. + this.addEventListener('range_change', function() { + presets.forEach(function(preset) { + if ( + preset.range_begin.isSame(self.state_.range_begin) && + preset.range_end.isSame(self.state_.range_end) + ) { + preset.button.set_background_color(beestat.style.color.lightblue.base); + } else { + preset.button.set_background_color(beestat.style.color.bluegray.base); + } + }); + }); + + button_group.render(column); +}; + +/** + * Decorate the error area. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.download_data.prototype.decorate_error_ = function(parent) { + var self = this; + + var div = $.createElement('div').style('color', beestat.style.color.red.base); + + // Display errors as necessary. + this.addEventListener('range_change', function() { + div.innerHTML(''); + if (self.state_.range_begin.isValid() === false) { + div.appendChild($.createElement('div').innerText('Invalid begin date.')); + } + if (self.state_.range_end.isValid() === false) { + div.appendChild($.createElement('div').innerText('Invalid end date.')); + } + }); + + parent.appendChild(div); }; /** @@ -154,25 +241,23 @@ beestat.component.modal.download_data.prototype.get_buttons_ = function() { .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') + .set_text('Download') .addEventListener('click', function() { - var download_begin; - var download_end = null; - if (self.state_.download_data_time_period === 'all') { - download_begin = null; + var range_begin; + var range_end; + if (self.state_.range_end.isBefore(self.state_.range_begin) === true) { + range_begin = self.state_.range_end; + range_end = self.state_.range_begin; } 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'); + range_begin = self.state_.range_begin; + range_end = self.state_.range_end; } var download_arguments = { 'thermostat_id': beestat.setting('thermostat_id'), - 'download_begin': download_begin, - 'download_end': download_end + 'download_begin': range_begin.format(), + 'download_end': range_end.hour(23).minute(55) + .format() }; window.location.href = '/api/?resource=runtime&method=download&arguments=' + JSON.stringify(download_arguments) + '&api_key=' + beestat.api.api_key;