1
0
mirror of https://github.com/beestat/app.git synced 2025-07-09 03:04:07 -04:00

A bunch of changes in preparation for sensor data.

The big stuff is done; sending this live to identify any issues early.
This commit is contained in:
Jon Ziebell 2020-01-12 21:27:30 -05:00
parent e5b1f5538f
commit 24020e300f
18 changed files with 2018 additions and 797 deletions

View File

@ -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;

843
api/runtime.php Normal file
View File

@ -0,0 +1,843 @@
<?php
/**
* All of the raw thermostat data sits here. Many millions of rows.
*
* @author Jon Ziebell
*/
class runtime extends cora\api {
public static $exposed = [
'private' => [
'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);
}
}
}

138
api/runtime_sensor.php Normal file
View File

@ -0,0 +1,138 @@
<?php
/**
* All of the raw sensor data sits here. Many millions of rows.
*
* @author Jon Ziebell
*/
class runtime_sensor extends cora\crud {
public static $exposed = [
'private' => [
'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);
}
}
}

View File

@ -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

View File

@ -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"; }

View File

@ -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) {

View File

@ -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',

View File

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

View File

@ -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;

View File

@ -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
};
};

View File

@ -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() {

View File

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

View File

@ -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() {

View File

@ -37,6 +37,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
echo '<script src="/js/layer/load.js"></script>' . PHP_EOL;
echo '<script src="/js/layer/dashboard.js"></script>' . PHP_EOL;
echo '<script src="/js/layer/home_comparisons.js"></script>' . PHP_EOL;
echo '<script src="/js/layer/sensors.js"></script>' . PHP_EOL;
// Component
echo '<script src="/js/component.js"></script>' . PHP_EOL;
@ -50,7 +51,8 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
echo '<script src="/js/component/card/footer.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/my_home.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/patreon.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/runtime_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/runtime_thermostat_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/runtime_sensor_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/score.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/score/cool.js"></script>' . PHP_EOL;
echo '<script src="/js/component/card/score/heat.js"></script>' . PHP_EOL;
@ -61,7 +63,8 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
echo '<script src="/js/component/chart.js"></script>' . PHP_EOL;
echo '<script src="/js/component/chart/runtime_thermostat_summary.js"></script>' . PHP_EOL;
echo '<script src="/js/component/chart/temperature_profiles.js"></script>' . PHP_EOL;
echo '<script src="/js/component/chart/runtime_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/chart/runtime_thermostat_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/chart/runtime_sensor_detail.js"></script>' . PHP_EOL;
echo '<script src="/js/component/header.js"></script>' . PHP_EOL;
echo '<script src="/js/component/icon.js"></script>' . PHP_EOL;
echo '<script src="/js/component/layout.js"></script>' . PHP_EOL;
@ -78,7 +81,8 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
echo '<script src="/js/component/modal/error.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/filter_info.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/enjoy_beestat.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/runtime_detail_custom.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/runtime_thermostat_detail_custom.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/runtime_sensor_detail_custom.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/thermostat_info.js"></script>' . PHP_EOL;
echo '<script src="/js/component/modal/weather.js"></script>' . PHP_EOL;
echo '<script src="/js/component/input.js"></script>' . PHP_EOL;

View File

@ -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

View File

@ -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

59
js/layer/sensors.js Normal file
View File

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