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);
+};