1
0
mirror of https://github.com/beestat/app.git synced 2025-05-23 18:04:14 -04:00
beestat/api/ecobee_runtime_thermostat.php
2019-05-22 21:22:24 -04:00

490 lines
17 KiB
PHP

<?php
/**
* All of the raw thermostat data sits here. Many millions of rows.
*
* @author Jon Ziebell
*/
class ecobee_runtime_thermostat extends cora\crud {
public static $exposed = [
'private' => [
'get_recent_activity',
'get_aggregate_runtime',
'sync'
],
'public' => []
];
public static $cache = [
'sync' => 3600, // 1 Hour
'get_recent_activity' => 300, // 5 Minutes
'get_aggregate_runtime' => 3600, // 1 Hour
];
public static $converged = [];
public static $user_locked = true;
/**
* 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 = 'ecobee_runtime_thermostat->sync(' . $thermostat_id . ')';
$this->database->get_lock($lock_name);
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
if($thermostat['sync_begin'] === null) {
$this->sync_backwards($thermostat_id);
} else {
$this->sync_forwards($thermostat_id);
}
// TODO: 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' => 'ecobee_runtime_thermostat'
]
);
$this->database->release_lock($lock_name);
}
}
/**
* Sync backwards from now until thermostat.first_connected. This should
* only be used when syncing for the first time.
*
* @param int $ecobee_thermostat_id
*/
private function sync_backwards($thermostat_id) {
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
if($thermostat['sync_begin'] !== null) {
throw new \Exception('Full sync already performed; must call sync_forwards() now.');
}
// Sync from when the thermostat was first connected until now.
$sync_begin = strtotime($thermostat['first_connected']);
$sync_end = time();
$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.
do {
$this->database->start_transaction();
$chunk_begin = strtotime('-1 week', $chunk_end);
$chunk_begin = max($chunk_begin, $sync_begin);
$this->sync_($thermostat['ecobee_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', $sync_end),
]
]
);
// 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.
$sync_begin = strtotime($thermostat['sync_end']);
$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['ecobee_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);
}
/**
* Get the runtime report data for a specified thermostat.
*
* @param int $ecobee_thermostat_id
* @param int $begin
* @param int $end
*/
private function sync_($ecobee_thermostat_id, $begin, $end) {
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $ecobee_thermostat_id);
/**
* TODO: There is some issue related to the sync where we can miss small
* chunks of time if begin/end are the same day or something like that. It
* seems to happen around UTC 00:00:00 so 7:00pm or so local time. This
* happens to fix it by forcing sycing backwards by an extra day so that
* chunk of time can't be missed. Need to properly fix...maybe next time I
* take a pass at the syncing...
*/
if(date('Y-m-d', $begin) === date('Y-m-d', $end)) {
$begin = strtotime('-1 day', $begin);
}
$begin = date('Y-m-d', $begin);
$end = date('Y-m-d', $end);
$columns = [
'auxHeat1' => 'auxiliary_heat_1',
'auxHeat2' => 'auxiliary_heat_2',
'auxHeat3' => 'auxiliary_heat_3',
'compCool1' => 'compressor_cool_1',
'compCool2' => 'compressor_cool_2',
'compHeat1' => 'compressor_heat_1',
'compHeat2' => 'compressor_heat_2',
'dehumidifier' => 'dehumidifier',
'dmOffset' => 'demand_management_offset',
'economizer' => 'economizer',
'fan' => 'fan',
'humidifier' => 'humidifier',
'hvacMode' => 'hvac_mode',
'outdoorHumidity' => 'outdoor_humidity',
'outdoorTemp' => 'outdoor_temperature',
'sky' => 'sky',
'ventilator' => 'ventilator',
'wind' => 'wind',
'zoneAveTemp' => 'zone_average_temperature',
'zoneCalendarEvent' => 'zone_calendar_event',
'zoneClimate' => 'zone_climate',
'zoneCoolTemp' => 'zone_cool_temperature',
'zoneHeatTemp' => 'zone_heat_temperature',
'zoneHumidity' => 'zone_humidity',
'zoneHumidityHigh' => 'zone_humidity_high',
'zoneHumidityLow' => 'zone_humidity_low',
'zoneHvacMode' => 'zone_hvac_mode',
'zoneOccupancy' => 'zone_occupancy'
];
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'GET',
'endpoint' => 'runtimeReport',
'arguments' => [
'body' => json_encode([
'selection' => [
'selectionType' => 'thermostats',
'selectionMatch' => $ecobee_thermostat['identifier']
],
'startDate' => $begin,
'endDate' => $end,
'columns' => implode(',', array_keys($columns)),
'includeSensors' => false
])
]
]
);
$time_zone_offset = $ecobee_thermostat['json_location']['timeZoneOffsetMinutes'];
foreach($response['reportList'][0]['rowList'] as $row) {
// Prepare the row!
$row = substr($row, 0, -1); // Strip the trailing comma,
$row = explode(',', $row);
$row = array_map('trim', $row);
// Date and time are first two columns of the returned data. It is
// returned in thermostat time, so convert it to UTC first.
list($date, $time) = array_splice($row, 0, 2);
$timestamp = date(
'Y-m-d H:i:s',
strtotime(
$date . ' ' . $time . ' ' . ($time_zone_offset < 0 ? '+' : '-') . abs($time_zone_offset) . ' minute'
)
);
$data = [
'ecobee_thermostat_id' => $ecobee_thermostat_id,
'timestamp' => $timestamp
];
$i = 0;
foreach($columns as $ecobee_key => $database_key) {
$data[$database_key] = ($row[$i] === '' ? null : $row[$i]);
$i++;
}
$existing_rows = $this->read([
'ecobee_thermostat_id' => $ecobee_thermostat_id,
'timestamp' => $timestamp
]);
if(count($existing_rows) > 0) {
$data['ecobee_runtime_thermostat_id'] = $existing_rows[0]['ecobee_runtime_thermostat_id'];
$this->update($data);
}
else {
$this->create($data);
}
}
}
/**
* Query thermostat data and aggregate the results.
*
* @param int $ecobee_thermostat_id Thermostat to get data for.
* @param string $time_period day|week|month|year|all
* @param string $group_by hour|day|week|month|year
* @param int $time_count How many time periods to include.
*
* @return array The aggregate runtime data.
*/
public function get_aggregate_runtime($ecobee_thermostat_id, $time_period, $group_by, $time_count) {
if(in_array($time_period, ['day', 'week', 'month', 'year', 'all']) === false) {
throw new Exception('Invalid time period');
}
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $ecobee_thermostat_id);
$this->database->set_time_zone($ecobee_thermostat['json_location']['timeZoneOffsetMinutes']);
$select = [];
$group_by_order_by = [];
switch($group_by) {
case 'hour':
$select[] = 'hour(`timestamp`) `hour`';
$group_by_order_by[] = 'hour(`timestamp`)';
case 'day':
$select[] = 'day(`timestamp`) `day`';
$group_by_order_by[] = 'day(`timestamp`)';
case 'week':
$select[] = 'week(`timestamp`) `week`';
$group_by_order_by[] = 'week(`timestamp`)';
case 'month':
$select[] = 'month(`timestamp`) `month`';
$group_by_order_by[] = 'month(`timestamp`)';
case 'year':
$select[] = 'year(`timestamp`) `year`';
$group_by_order_by[] = 'year(`timestamp`)';
break;
}
$group_by_order_by = array_reverse($group_by_order_by);
// This is a smidge sloppy but it gets the job done. Basically need to
// subtract all higher tier heat/cool modes from the lower ones to avoid
// double-counting.
$select[] = 'count(*) `count`';
$select[] = 'cast(avg(`outdoor_temperature`) as decimal(4,1)) `outdoor_temperature`';
$select[] = 'cast(avg(`zone_average_temperature`) as decimal(4,1)) `zone_average_temperature`';
$select[] = 'cast(avg(`zone_heat_temperature`) as decimal(4,1)) `zone_heat_temperature`';
$select[] = 'cast(avg(`zone_cool_temperature`) as decimal(4,1)) `zone_cool_temperature`';
$select[] = 'cast(sum(greatest(0, (cast(`compressor_heat_1` as signed) - cast(`compressor_heat_2` as signed)))) as unsigned) `compressor_heat_1`';
$select[] = 'cast(sum(`compressor_heat_2`) as unsigned) `compressor_heat_2`';
$select[] = 'cast(sum(greatest(0, (cast(`auxiliary_heat_1` as signed) - cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed)))) as unsigned) `auxiliary_heat_1`';
$select[] = 'cast(sum(greatest(0, (cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed)))) as unsigned) `auxiliary_heat_2`';
$select[] = 'cast(sum(`auxiliary_heat_3`) as unsigned) `auxiliary_heat_3`';
$select[] = 'cast(sum(greatest(0, (cast(`compressor_cool_1` as signed) - cast(`compressor_cool_2` as signed)))) as unsigned) `compressor_cool_1`';
$select[] = 'cast(sum(`compressor_cool_2`) as unsigned) `compressor_cool_2`';
// The zone_average_temperature check is for if data exists in the table but
// is otherwise likely to be all null (like the bad data from February
// 2019).
$query = '
select ' .
implode(',', $select) . ' ' . '
from
`ecobee_runtime_thermostat`
where
`user_id` = ' . $this->session->get_user_id() . '
and `ecobee_thermostat_id` = "' . $this->database->escape($ecobee_thermostat_id) . '" ' .
($time_period !== 'all' ? ('and `timestamp` > now() - interval ' . intval($time_count) . ' ' . $time_period) : '') . '
and `timestamp` <= now()
and `zone_average_temperature` is not null
';
if(count($group_by_order_by) > 0) {
$query .= 'group by ' .
implode(', ', $group_by_order_by) . '
order by ' .
implode(', ', $group_by_order_by);
}
$result = $this->database->query($query);
$return = [];
while($row = $result->fetch_assoc()) {
// Cast to floats for nice responses. The database normally handles this
// in regular read operations.
foreach(['outdoor_temperature', 'zone_average_temperature', 'zone_heat_temperature', 'zone_cool_temperature'] as $key) {
if($row[$key] !== null) {
$row[$key] = (float) $row[$key];
}
}
$return[] = $row;
}
$this->database->set_time_zone(0);
return $return;
}
/**
* Get recent thermostat activity. Max range is 30 days.
*
* @param int $ecobee_thermostat_id Thermostat to get data for.
* @param string $begin Begin date/time.
* @param string $end End date/time.
*
* @return array The rows in the desired date range.
*/
public function get_recent_activity($ecobee_thermostat_id, $begin, $end) {
$thermostat = $this->api(
'thermostat',
'get',
[
'attributes' => [
'ecobee_thermostat_id' => $ecobee_thermostat_id
]
]
);
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $thermostat['ecobee_thermostat_id']);
$this->database->set_time_zone($ecobee_thermostat['json_location']['timeZoneOffsetMinutes']);
$offset = $ecobee_thermostat['json_location']['timeZoneOffsetMinutes'];
$end = ($end === null ? (time() + ($offset * 60)) : strtotime($end));
$begin = ($begin === null ? strtotime('-14 day', $end) : strtotime($begin));
if(($end - $begin) > 2592000) {
throw new Exception('Date range exceeds maximum of 30 days.');
}
$query = '
select
`ecobee_thermostat_id`,
`timestamp`,
cast(greatest(0, (cast(`compressor_heat_1` as signed) - cast(`compressor_heat_2` as signed))) as unsigned) `compressor_heat_1`,
`compressor_heat_2`,
cast(greatest(0, (cast(`auxiliary_heat_1` as signed) - cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed))) as unsigned) `auxiliary_heat_1`,
cast(greatest(0, (cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed))) as unsigned) `auxiliary_heat_2`,
`auxiliary_heat_3`,
cast(greatest(0, (cast(`compressor_cool_1` as signed) - cast(`compressor_cool_2` as signed))) as unsigned) `compressor_cool_1`,
`compressor_cool_2`,
`fan`,
`dehumidifier`,
`economizer`,
`humidifier`,
`ventilator`,
`hvac_mode`,
`outdoor_temperature`,
`zone_average_temperature`,
`zone_heat_temperature`,
`zone_cool_temperature`,
`zone_humidity`,
`outdoor_humidity`,
`zone_calendar_event`,
`zone_climate`
from
`ecobee_runtime_thermostat`
where
`user_id` = ' . $this->database->escape($this->session->get_user_id()) . '
and `ecobee_thermostat_id` = ' . $this->database->escape($ecobee_thermostat_id) . '
and `timestamp` >= ' . $this->database->escape(date('Y-m-d H:i:s', $begin)) . '
and `timestamp` <= ' . $this->database->escape(date('Y-m-d H:i:s', $end)) . '
order by
timestamp
';
$result = $this->database->query($query);
$return = [];
while($row = $result->fetch_assoc()) {
$return[] = $row;
}
$this->database->set_time_zone(0);
return $return;
}
}