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;