[ 'read', 'sync' ], 'public' => [] ]; public static $cache = [ 'sync' => 900, // 15 Minutes '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; /** * 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 { $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) { $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( $columns_begin['date'] . ' ' . $columns_begin['time'], $thermostat['time_zone'] ), $this->get_utc_datetime( $columns_end['date'] . ' ' . $columns_end['time'], $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']; $data['fan'] = $columns['fan']; 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; } $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); $date_time = new DateTime($local_datetime, $local_time_zone); $date_time->setTimezone(new DateTimeZone('UTC')); 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 * 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 = []) { $thermostats = $this->api('thermostat', 'read_id'); // Check for exceptions. if (isset($attributes['thermostat_id']) === false) { throw new \Exception('Missing required attribute: thermostat_id.', 10201); } if (isset($attributes['timestamp']) === false) { throw new \Exception('Missing required attribute: timestamp.', 10202); } if (isset($thermostats[$attributes['thermostat_id']]) === false) { throw new \Exception('Invalid thermostat_id.', 10203); } if ( is_array($attributes['timestamp']) === true && in_array($attributes['timestamp']['operator'], ['>', '>=', '<', '<=']) === true && 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.', 10204); } } $thermostat = $thermostats[$attributes['thermostat_id']]; $max_range = 2592000; // 30 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 30 days.', 10205); } // Read the data. $runtime_thermostats = $this->database->read( 'runtime_thermostat', [ 'timestamp' => $attributes['timestamp'], 'thermostat_id' => $attributes['thermostat_id'] ], [], '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 ] ] ); // Clean up the data just a bit. foreach ($runtime_thermostats as &$runtime_thermostat) { $runtime_thermostat['timestamp'] = date( 'c', strtotime($runtime_thermostat['timestamp']) ); foreach([ 'indoor_temperature', 'outdoor_temperature', 'setpoint_cool', 'setpoint_heat' ] as $key) { if ($runtime_thermostat[$key] !== null) { $runtime_thermostat[$key] /= 10; } } if ($runtime_thermostat['event_runtime_thermostat_text_id'] !== null) { $runtime_thermostat['event'] = $runtime_thermostat_texts[ $runtime_thermostat['event_runtime_thermostat_text_id'] ]['value']; } else { $runtime_thermostat['event'] = null; } unset($runtime_thermostat['event_runtime_thermostat_text_id']); if ($runtime_thermostat['climate_runtime_thermostat_text_id'] !== null) { $runtime_thermostat['climate'] = $runtime_thermostat_texts[ $runtime_thermostat['climate_runtime_thermostat_text_id'] ]['value']; } else { $runtime_thermostat['climate'] = null; } unset($runtime_thermostat['climate_runtime_thermostat_text_id']); } return $runtime_thermostats; } }