diff --git a/js/beestat/runtime_sensor.js b/js/beestat/runtime_sensor.js new file mode 100644 index 0000000..992275d --- /dev/null +++ b/js/beestat/runtime_sensor.js @@ -0,0 +1,244 @@ +beestat.runtime_sensor = {}; + +/** + * Get a bunch of data for the current runtime_sensor rows. Includes basically + * everything you need to make a cool chart. + * + * @param {number} thermostat_id The thermostat_id to get data for. + * @param {object} range Range settings. + * + * @return {object} The data. + */ +beestat.runtime_sensor.get_data = function(thermostat_id, range) { + var data = { + 'x': [], + 'series': {}, + 'metadata': { + 'series': {}, + 'chart': { + 'y_min': Infinity, + 'y_max': -Infinity, + 'sensors': null + } + } + }; + + // 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); + } + }; + + // Duration objects. These are passed by reference into the metadata. + var durations = {}; + + var series_codes = []; + + // Get and sort all the sensors. + var sensors = beestat.sensor.get_sorted(); + data.metadata.sensors = sensors; + + // Set up the series_codes. + sensors.forEach(function(sensor) { + if (sensor.thermostat_id === 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, + 'durations': {}, + 'data': {} + }; + 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; + } + + durations[series_code] = {'seconds': 0}; + }); + + var begin_m; + var end_m; + if (range.type === 'dynamic') { + begin_m = moment().subtract( + range.dynamic, + 'day' + ); + end_m = moment().subtract(1, 'hour'); + } else { + begin_m = moment( + range.static_begin + ' 00:00:00' + ); + end_m = moment( + 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[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 = beestat.runtime_sensor.get_runtime_sensors_by_date_(); + + // Initialize moving average. + var moving = []; + var moving_count; + if (beestat.setting('runtime_sensor_detail_smoothing') === true) { + moving_count = 5; + } 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(70); + data.metadata.series.dummy.active = true; + + if (runtime_sensors[current_m.valueOf()] !== undefined) { + sensors.forEach(function(sensor, j) { + var runtime_sensor = runtime_sensors[current_m.valueOf()][sensor.sensor_id]; + if (runtime_sensor === undefined) { + data.series['temperature_' + sensor.sensor_id].push(null); + data.series['occupancy_' + sensor.sensor_id].push(null); + return; + } + + var temperature_moving = beestat.temperature( + beestat.runtime_sensor.get_average_(moving, sensor.sensor_id) + ); + 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_occupancy.get_swimlane_properties( + sensors.length, + j + ); + + sequential['occupancy_' + runtime_sensor.sensor_id]++; + data.series['occupancy_' + runtime_sensor.sensor_id].push(swimlane_properties.y); + data.metadata.series['occupancy_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = swimlane_properties.y; + } else { + if (sequential['occupancy_' + runtime_sensor.sensor_id] > 0) { + let swimlane_properties = + beestat.component.chart.runtime_sensor_detail_occupancy.get_swimlane_properties( + sensors.length, + j + ); + + data.series['occupancy_' + runtime_sensor.sensor_id].push(swimlane_properties.y); + data.metadata.series['occupancy_' + runtime_sensor.sensor_id].data[current_m.valueOf()] = swimlane_properties.y; + } else { + data.series['occupancy_' + runtime_sensor.sensor_id].push(null); + } + sequential['occupancy_' + runtime_sensor.sensor_id] = 0; + } + }); + } else { + sensors.forEach(function(sensor) { + if (sensor.thermostat_id === 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.runtime_sensor.get_runtime_sensors_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][runtime_sensor.sensor_id] = 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} sensor_id The index in the sub-array + * + * @return {number} The average. + */ +beestat.runtime_sensor.get_average_ = function(runtime_sensors, sensor_id) { + var average = 0; + var count = 0; + for (var i = 0; i < runtime_sensors.length; i++) { + if ( + runtime_sensors[i] !== undefined && + runtime_sensors[i][sensor_id] !== undefined && + runtime_sensors[i][sensor_id].temperature !== null + ) { + average += runtime_sensors[i][sensor_id].temperature; + count++; + } + } + + if (count === 0) { + return null; + } + + return average / count; +}; diff --git a/js/beestat/runtime_thermostat.js b/js/beestat/runtime_thermostat.js new file mode 100644 index 0000000..50005f1 --- /dev/null +++ b/js/beestat/runtime_thermostat.js @@ -0,0 +1,560 @@ +beestat.runtime_thermostat = {}; + +/** + * Get a bunch of data for the current runtime_thermostat rows. Includes + * basically everything you need to make a cool chart. + * + * @param {number} thermostat_id The thermostat_id to get data for. + * @param {object} range Range settings. + * + * @return {object} The data. + */ +beestat.runtime_thermostat.get_data = function(thermostat_id, range) { + var data = { + 'x': [], + 'series': {}, + 'metadata': { + 'series': {}, + 'chart': { + '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); + } + }; + + // Duration objects. These are passed by reference into the metadata. + var durations = {}; + + // Y values for equipment swimlane data. + var equipment_y = { + 'calendar_event_smartrecovery': 3, + 'calendar_event_home': 3, + 'calendar_event_away': 3, + 'calendar_event_sleep': 3, + 'calendar_event_smarthome': 3, + 'calendar_event_smartaway': 3, + 'calendar_event_hold': 3, + 'calendar_event_vacation': 3, + 'calendar_event_quicksave': 3, + 'calendar_event_other': 3, + 'calendar_event_custom': 3, + 'compressor_heat_1': 16, + 'compressor_heat_2': 16, + 'auxiliary_heat_1': 16, + 'auxiliary_heat_2': 16, + 'compressor_cool_1': 16, + 'compressor_cool_2': 16, + 'fan': 29, + 'humidifier': 39, + 'dehumidifier': 39, + 'ventilator': 39, + 'economizer': 39 + }; + + // Initialize a bunch of stuff. + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom', + 'outdoor_temperature', + 'indoor_temperature', + 'indoor_humidity', + 'outdoor_humidity', + 'setpoint_heat', + 'setpoint_cool', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'fan', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer', + 'dummy' + ].forEach(function(series_code) { + data.series[series_code] = []; + data.metadata.series[series_code] = { + 'active': false, + 'durations': {}, + + /** + * Note to future self: This can be used for all series. Need to + * populate the raw data points for each series here. The tooltip should + * get data from here and not the chart points array. Then the series + * data can be whatever is necessary to produce a performance-optimized + * chart as long as there is one series (dummy) that has a point at + * every x-value. That will allow a smooth tooltip, lightweight lines, + * and accurate data. + */ + 'data': {} + }; + durations[series_code] = {'seconds': 0}; + }); + + data.metadata.series.calendar_event_name = {}; + data.metadata.series.system_mode = {}; + + var begin_m; + var end_m; + if (range.type === 'dynamic') { + begin_m = moment().subtract( + range.dynamic, + 'day' + ); + end_m = moment().subtract(1, 'hour'); + } else { + begin_m = moment( + range.static_begin + ' 00:00:00' + ); + end_m = moment( + range.static_end + ' 23:59:59' + ); + } + + var thermostat = beestat.cache.thermostat[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_thermostats = beestat.runtime_thermostat.get_runtime_thermostats_by_date_(); + + // Initialize moving average. + var moving = []; + var moving_count; + if (beestat.setting('runtime_thermostat_detail_smoothing') === true) { + moving_count = 10; + } 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_thermostats[begin_m.valueOf() + offset]); + } + + // Loop. + var current_m = begin_m; + while ( + 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(70); + data.metadata.series.dummy.active = true; + + var runtime_thermostat = runtime_thermostats[ + current_m.valueOf() + ]; + + if (runtime_thermostat !== undefined) { + /** + * Things that use the moving average. + */ + var indoor_humidity_moving = beestat.runtime_thermostat.get_average_(moving, 'indoor_humidity'); + data.series.indoor_humidity.push(indoor_humidity_moving); + data.metadata.series.indoor_humidity.data[current_m.valueOf()] = + runtime_thermostat.indoor_humidity; + data.metadata.series.indoor_humidity.active = true; + + var outdoor_humidity_moving = beestat.runtime_thermostat.get_average_(moving, 'outdoor_humidity'); + data.series.outdoor_humidity.push(outdoor_humidity_moving); + data.metadata.series.outdoor_humidity.data[current_m.valueOf()] = + runtime_thermostat.outdoor_humidity; + data.metadata.series.outdoor_humidity.active = true; + + var indoor_temperature_moving = beestat.temperature( + beestat.runtime_thermostat.get_average_(moving, 'indoor_temperature') + ); + data.series.indoor_temperature.push(indoor_temperature_moving); + data.metadata.series.indoor_temperature.data[current_m.valueOf()] = + beestat.temperature(runtime_thermostat.indoor_temperature); + y_min_max(indoor_temperature_moving); + data.metadata.series.indoor_temperature.active = true; + + var outdoor_temperature_moving = beestat.temperature( + beestat.runtime_thermostat.get_average_(moving, 'outdoor_temperature') + ); + data.series.outdoor_temperature.push(outdoor_temperature_moving); + data.metadata.series.outdoor_temperature.data[current_m.valueOf()] = + beestat.temperature(runtime_thermostat.outdoor_temperature); + y_min_max(outdoor_temperature_moving); + data.metadata.series.outdoor_temperature.active = true; + + /** + * Add setpoints, but only when relevant. For example: Only show the + * heat setpoint line when the heat is actually on. + */ + if ( + runtime_thermostat.system_mode === 'auto' || + runtime_thermostat.system_mode === 'heat' || + runtime_thermostat.system_mode === 'auxiliary_heat' + ) { + var setpoint_heat = beestat.temperature( + runtime_thermostat.setpoint_heat + ); + data.series.setpoint_heat.push(setpoint_heat); + y_min_max(setpoint_heat); + + data.metadata.series.setpoint_heat.active = true; + + } else { + data.series.setpoint_heat.push(null); + } + + if ( + runtime_thermostat.system_mode === 'auto' || + runtime_thermostat.system_mode === 'cool' + ) { + var setpoint_cool = beestat.temperature( + runtime_thermostat.setpoint_cool + ); + data.series.setpoint_cool.push(setpoint_cool); + y_min_max(setpoint_cool); + + data.metadata.series.setpoint_cool.active = true; + + } else { + data.series.setpoint_cool.push(null); + } + + /* + * HVAC Mode. This isn't graphed but it's available for the tooltip. + * series.system_mode.chart_data.push([x, runtime_thermostat.system_mode]); + */ + data.metadata.series.system_mode[current_m.valueOf()] = runtime_thermostat.system_mode; + + /* + * Thanks, ecobee...I more or less copied this code from the ecobee Follow + * Me graph to make sure it's accurate. + */ + var this_calendar_event; + var this_calendar_event_name; + + if (runtime_thermostat.event === null) { + if (runtime_thermostat.climate === null) { + this_calendar_event = 'calendar_event_other'; + this_calendar_event_name = 'Other'; + } else { + switch (runtime_thermostat.climate.toLowerCase()) { + case 'home': + case 'sleep': + case 'away': + this_calendar_event = 'calendar_event_' + runtime_thermostat.climate.toLowerCase(); + this_calendar_event_name = runtime_thermostat.climate; + break; + default: + this_calendar_event = 'calendar_event_custom'; + this_calendar_event_name = runtime_thermostat.climate; + break; + } + } + } else if (runtime_thermostat.event.match(/SmartRecovery/i) !== null) { + this_calendar_event = 'calendar_event_smartrecovery'; + this_calendar_event_name = 'Smart Recovery'; + } else if (runtime_thermostat.event.match(/^home$/i) !== null) { + this_calendar_event = 'calendar_event_home'; + this_calendar_event_name = 'Home'; + } else if (runtime_thermostat.event.match(/^away$/i) !== null) { + this_calendar_event = 'calendar_event_away'; + this_calendar_event_name = 'Away'; + } else if (runtime_thermostat.event.match(/^smarthome$/i) !== null) { + this_calendar_event = 'calendar_event_smarthome'; + this_calendar_event_name = 'Smart Home'; + } else if (runtime_thermostat.event.match(/^smartaway$/i) !== null) { + this_calendar_event = 'calendar_event_smartaway'; + this_calendar_event_name = 'Smart Away'; + } else if ( + runtime_thermostat.event.match(/^auto$/i) !== null || + runtime_thermostat.event.match(/^today$/i) !== null || + runtime_thermostat.event.match(/^hold$/i) !== null + ) { + this_calendar_event = 'calendar_event_hold'; + this_calendar_event_name = 'Hold'; + } else if ( + runtime_thermostat.event.match(/^vacation$/i) !== null || + runtime_thermostat.event.match(/(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i) !== null + ) { + this_calendar_event = 'calendar_event_vacation'; + this_calendar_event_name = 'Vacation'; + } else if (runtime_thermostat.event.match(/^quicksave$/i) !== null) { + this_calendar_event = 'calendar_event_quicksave'; + this_calendar_event_name = 'Quick Save'; + } else { + this_calendar_event = 'calendar_event_other'; + this_calendar_event_name = 'Other'; + } + + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom' + ].forEach(function(calendar_event) { + if (calendar_event === this_calendar_event) { + data.series[calendar_event].push(equipment_y[calendar_event]); + data.metadata.series[calendar_event].data[current_m.valueOf()] = + equipment_y[calendar_event]; + data.metadata.series[calendar_event].active = true; + } else { + data.series[calendar_event].push(null); + } + }); + + data.metadata.series.calendar_event_name[current_m.valueOf()] = + this_calendar_event_name; + + /** + * If all stages of the compressor are off, clear the durations. It is + * important that this only get reset if the seconds values are also + * zero to support backfilling. + */ + if ( + runtime_thermostat.compressor_1 === 0 && + runtime_thermostat.compressor_2 === 0 && + ( + durations.compressor_heat_1.seconds > 0 || + durations.compressor_heat_2.seconds > 0 || + durations.compressor_cool_1.seconds > 0 || + durations.compressor_cool_2.seconds > 0 + ) + ) { + durations.compressor_heat_1 = {'seconds': 0}; + durations.compressor_heat_2 = {'seconds': 0}; + durations.compressor_cool_1 = {'seconds': 0}; + durations.compressor_cool_2 = {'seconds': 0}; + } + + if ( + runtime_thermostat.auxiliary_heat_1 === 0 && + runtime_thermostat.auxiliary_heat_2 === 0 && + ( + durations.auxiliary_heat_1.seconds > 0 || + durations.auxiliary_heat_2.seconds > 0 + ) + ) { + durations.auxiliary_heat_1 = {'seconds': 0}; + durations.auxiliary_heat_2 = {'seconds': 0}; + } + + // Reset fan to 0 + if (runtime_thermostat.fan === 0) { + durations.fan = {'seconds': 0}; + } + + // Reset accessories + if (runtime_thermostat.accessory === 0) { + durations.humidifier = {'seconds': 0}; + durations.dehumidifier = {'seconds': 0}; + durations.ventilator = {'seconds': 0}; + durations.economizer = {'seconds': 0}; + } + + // Equipment + [ + 'fan', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer' + ].forEach(function(series_code) { + var runtime_thermostat_series_code; + switch (series_code) { + case 'compressor_heat_1': + case 'compressor_heat_2': + runtime_thermostat_series_code = series_code + .replace('compressor_heat', 'compressor'); + break; + case 'compressor_cool_1': + case 'compressor_cool_2': + runtime_thermostat_series_code = series_code + .replace('compressor_cool', 'compressor'); + break; + case 'humidifier': + case 'dehumidifier': + case 'ventilator': + case 'economizer': + runtime_thermostat_series_code = 'accessory'; + break; + default: + runtime_thermostat_series_code = series_code; + break; + } + + var equipment_on = function(series_code_on, runtime_thermostat_series_code_on) { + switch (series_code_on) { + case 'compressor_heat_1': + case 'compressor_heat_2': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.compressor_mode === 'heat'; + case 'compressor_cool_1': + case 'compressor_cool_2': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.compressor_mode === 'cool'; + case 'humidifier': + case 'dehumidifier': + case 'ventilator': + case 'economizer': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.accessory_type === series_code; + default: + return runtime_thermostat[series_code] > 0; + } + }; + + if (equipment_on(series_code, runtime_thermostat_series_code) === true) { + data.metadata.series[series_code].active = true; + data.metadata.series[series_code].durations[current_m.valueOf()] = durations[series_code]; + data.series[series_code].push(equipment_y[series_code]); + data.metadata.series[series_code].data[current_m.valueOf()] = + equipment_y[series_code]; + + if ( + series_code === 'auxiliary_heat_1' || + series_code === 'compressor_heat_1' || + series_code === 'compressor_cool_1' + ) { + var series_code_2 = series_code.replace('1', '2'); + data.metadata.series[series_code_2].durations[current_m.valueOf()] = durations[series_code_2]; + } + durations[series_code].seconds += runtime_thermostat[runtime_thermostat_series_code]; + + /* + * If heat/cool/aux 2 is on, extend the bar from heat/cool/aux 1 + * behind and set the duration. + */ + if (series_code.slice(-1) === '2') { + var series_code_1 = series_code.replace('2', '1'); + data.series[series_code_1] + .splice(-1, 1, equipment_y[series_code_1]); + data.metadata.series[series_code_1] + .durations[current_m.valueOf()] = durations[series_code_1]; + + data.metadata.series[series_code_1].data[current_m.valueOf()] = + equipment_y[series_code_1]; + + } + } else { + data.series[series_code].push(null); + } + }); + } else { + data.series.calendar_event_smartrecovery.push(null); + data.series.calendar_event_home.push(null); + data.series.calendar_event_away.push(null); + data.series.calendar_event_sleep.push(null); + data.series.calendar_event_smarthome.push(null); + data.series.calendar_event_smartaway.push(null); + data.series.calendar_event_hold.push(null); + data.series.calendar_event_vacation.push(null); + data.series.calendar_event_quicksave.push(null); + data.series.calendar_event_other.push(null); + data.series.calendar_event_custom.push(null); + data.series.indoor_temperature.push(null); + data.series.outdoor_temperature.push(null); + data.series.indoor_humidity.push(null); + data.series.outdoor_humidity.push(null); + data.series.setpoint_heat.push(null); + data.series.setpoint_cool.push(null); + data.series.fan.push(null); + data.series.compressor_heat_1.push(null); + data.series.compressor_heat_2.push(null); + data.series.auxiliary_heat_1.push(null); + data.series.auxiliary_heat_2.push(null); + data.series.compressor_cool_1.push(null); + data.series.compressor_cool_2.push(null); + data.series.humidifier.push(null); + data.series.dehumidifier.push(null); + data.series.ventilator.push(null); + data.series.economizer.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_thermostats[current_m.valueOf() + offset]); + } + + return data; +}; + +/** + * Given an array of runtime thermostats, 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_thermostats + * @param {string} series_code + * + * @return {number} The average. + */ +beestat.runtime_thermostat.get_average_ = function(runtime_thermostats, series_code) { + var average = 0; + var count = 0; + for (var i = 0; i < runtime_thermostats.length; i++) { + if (runtime_thermostats[i] !== undefined) { + average += runtime_thermostats[i][series_code]; + count++; + } + } + + return average / count; +}; + +/** + * Get all the runtime_thermostat rows indexed by date. + * + * @return {array} The runtime_thermostat rows. + */ +beestat.runtime_thermostat.get_runtime_thermostats_by_date_ = function() { + var runtime_thermostats = {}; + if (beestat.cache.runtime_thermostat !== undefined) { + beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat) { + runtime_thermostats[moment(runtime_thermostat.timestamp).valueOf()] = runtime_thermostat; + }); + } + return runtime_thermostats; +}; diff --git a/js/beestat/sensor.js b/js/beestat/sensor.js new file mode 100644 index 0000000..8a7c529 --- /dev/null +++ b/js/beestat/sensor.js @@ -0,0 +1,22 @@ +beestat.sensor = {}; + +/** + * Get a sorted list of all sensors attached to the current thermostat. + * + * @return {array} The sensors. + */ +beestat.sensor.get_sorted = function() { + // Get and sort all the sensors. + var sensors = []; + $.values(beestat.cache.sensor).forEach(function(sensor) { + if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + sensors.push(sensor); + } + }); + + sensors.sort(function(a, b) { + return a.name.localeCompare(b.name, 'en', {'sensitivity': 'base'}); + }); + + return sensors; +}; diff --git a/js/component/card.js b/js/component/card.js index dd9bbb1..cb321e4 100644 --- a/js/component/card.js +++ b/js/component/card.js @@ -87,7 +87,7 @@ beestat.component.card.prototype.decorate_title_ = function(parent) { : (beestat.style.size.gutter); if (title !== null) { parent.appendChild($.createElement('div') - .innerHTML(title) + .innerText(title) .style({ 'font-weight': beestat.style.font_weight.bold, 'font-size': beestat.style.font_size.large, diff --git a/js/component/card/runtime_sensor_detail.js b/js/component/card/runtime_sensor_detail.js index 83b0df5..431599e 100644 --- a/js/component/card/runtime_sensor_detail.js +++ b/js/component/card/runtime_sensor_detail.js @@ -27,6 +27,7 @@ beestat.component.card.runtime_sensor_detail = function(thermostat_id) { 'setting.runtime_sensor_detail_smoothing', 'setting.runtime_sensor_detail_range_type', 'setting.runtime_sensor_detail_range_dynamic', + 'cache.runtime_thermostat', 'cache.runtime_sensor' ], change_function @@ -44,10 +45,52 @@ beestat.extend(beestat.component.card.runtime_sensor_detail, beestat.component.c beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = function(parent) { var self = this; - var data = this.get_data_(); + var range = { + 'type': beestat.setting('runtime_sensor_detail_range_type'), + 'dynamic': beestat.setting('runtime_sensor_detail_range_dynamic'), + 'static_begin': beestat.setting('runtime_sensor_detail_range_static_begin'), + 'static_end': beestat.setting('runtime_sensor_detail_range_static_end') + }; - this.chart_ = new beestat.component.chart.runtime_sensor_detail(data); - this.chart_.render(parent); + var sensor_data = beestat.runtime_sensor.get_data(this.thermostat_id_, range); + var thermostat_data = beestat.runtime_thermostat.get_data(this.thermostat_id_, range); + + var data = sensor_data; + + Object.assign(data.series, thermostat_data.series); + Object.assign(data.metadata.series, thermostat_data.metadata.series); + + this.charts_ = { + 'equipment': new beestat.component.chart.runtime_thermostat_detail_equipment(data), + 'occupancy': new beestat.component.chart.runtime_sensor_detail_occupancy(data), + 'temperature': new beestat.component.chart.runtime_sensor_detail_temperature(data) + }; + + this.charts_.equipment.render(parent); + this.charts_.occupancy.render(parent); + this.charts_.temperature.render(parent); + + // Sync extremes and crosshair. + Object.values(this.charts_).forEach(function(source_chart) { + Object.values(self.charts_).forEach(function(target_chart) { + if (source_chart !== target_chart) { + target_chart.sync_extremes(source_chart); + target_chart.sync_crosshair(source_chart); + } + }); + }); + + // Keep the series list in sync across charts. + this.charts_.temperature.addEventListener('legend_item_click', function() { + this.get_chart().series.forEach(function(temperature_series) { + var occupancy_key = temperature_series.name.replace('temperature', 'occupancy'); + self.charts_.occupancy.get_chart().series.forEach(function(occupancy_series) { + if (occupancy_series.name === occupancy_key) { + occupancy_series.setVisible(temperature_series.visible); + } + }); + }); + }); var thermostat = beestat.cache.thermostat[this.thermostat_id_]; @@ -91,8 +134,11 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func * 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'); + if ( + beestat.cache.runtime_sensor === undefined || + beestat.cache.data.runtime_thermostat_last !== 'runtime_sensor_detail' + ) { + this.show_loading_('Loading Sensor Detail'); var value; var operator; @@ -108,9 +154,28 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func operator = 'between'; } +/* new beestat.api() + .add_call( + 'runtime_thermostat', + 'read', + { + 'attributes': { + 'thermostat_id': thermostat.thermostat_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + } + ) + .set_callback(function(response) { + beestat.cache.set('runtime_thermostat', response); + }) + .send();*/ + var api_call = new beestat.api(); - this.get_sensors_().forEach(function(sensor) { - if (sensor.thermostat_id === beestat.setting('thermostat_id')) { + beestat.sensor.get_sorted().forEach(function(sensor) { + if (sensor.thermostat_id === self.thermostat_id_) { api_call.add_call( 'runtime_sensor', 'read', @@ -122,23 +187,45 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func 'operator': operator } } - } + }, + 'runtime_sensor_' + sensor.sensor_id ); } }); + api_call.add_call( + 'runtime_thermostat', + 'read', + { + 'attributes': { + 'thermostat_id': thermostat.thermostat_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + }, + 'runtime_thermostat' + ); + api_call.set_callback(function(response) { var runtime_sensors = []; - response.forEach(function(r) { - runtime_sensors = runtime_sensors.concat(r); - }); + for (var alias in response) { + var r = response[alias]; + if (alias === 'runtime_thermostat') { + beestat.cache.set('data.runtime_thermostat_last', 'runtime_sensor_detail'); + beestat.cache.set('runtime_thermostat', r); + } else { + runtime_sensors = runtime_sensors.concat(r); + } + } beestat.cache.set('runtime_sensor', runtime_sensors); }); api_call.send(); } } else { - this.show_loading_('Syncing Runtime Detail'); + this.show_loading_('Syncing Sensor Detail'); setTimeout(function() { new beestat.api() .add_call( @@ -218,25 +305,25 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun } })); - // 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('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(); + self.charts_.temperature.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(); + self.charts_.temperature.reset_zoom(); })); if (beestat.setting('runtime_sensor_detail_smoothing') === true) { @@ -259,258 +346,17 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun .set_text('Help') .set_icon('help_circle') .set_callback(function() { - window.open('https://www.notion.so/beestat/891f94a6bdb34895a453b7b91591ec29'); + window.open('https://doc.beestat.io/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, - 'sensors': null - } - } - }; - - // 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 = []; - - // Get and sort all the sensors. - var sensors = this.get_sensors_(); - data.metadata.sensors = sensors; - - // Set up the series_codes. - sensors.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 = 5; - } 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]); - } - - // TODO: Garage sensor is not returned in runtime data until halfway - // through...so the series data never got added early on so it just gets - // slapped on the beginning. It also takes over a previous "j" value and - // pushes everything around. Instead of looping over runtime_sensor I need to loop over sensor and grab the values. - - // 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) { - sensors.forEach(function(sensor, j) { - var runtime_sensor = runtime_sensors[current_m.valueOf()][sensor.sensor_id]; - if (runtime_sensor === undefined) { - data.series['temperature_' + sensor.sensor_id].push(null); - data.series['occupancy_' + sensor.sensor_id].push(null); - return; - } - - var temperature_moving = beestat.temperature( - self.get_average_(moving, sensor.sensor_id) - ); - 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( - sensors.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( - sensors.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 { - sensors.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] = {}; - } - // runtime_sensors[timestamp].push(runtime_sensor); - runtime_sensors[timestamp][runtime_sensor.sensor_id] = 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} sensor_id The index in the sub-array - * - * @return {number} The average. - */ -beestat.component.card.runtime_sensor_detail.prototype.get_average_ = function(runtime_sensors, sensor_id) { - var average = 0; - var count = 0; - for (var i = 0; i < runtime_sensors.length; i++) { - if ( - runtime_sensors[i] !== undefined && - runtime_sensors[i][sensor_id] !== undefined && - runtime_sensors[i][sensor_id].temperature !== null - ) { - average += runtime_sensors[i][sensor_id].temperature; - count++; - } - } - - if (count === 0) { - return null; - } - - 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'; + return 'Sensor Detail'; }; /** @@ -561,24 +407,3 @@ beestat.component.card.runtime_sensor_detail.prototype.data_synced_ = function(r current_sync_end.isSameOrAfter(required_sync_end) ); }; - -/** - * Get a sorted list of all sensors attached to the current thermostat. - * - * @return {array} The sensors. - */ -beestat.component.card.runtime_sensor_detail.prototype.get_sensors_ = function() { - // Get and sort all the sensors. - var sensors = []; - $.values(beestat.cache.sensor).forEach(function(sensor) { - if (sensor.thermostat_id === beestat.setting('thermostat_id')) { - sensors.push(sensor); - } - }); - - sensors.sort(function(a, b) { - return a.name.localeCompare(b.name, 'en', {'sensitivity': 'base'}); - }); - - return sensors; -}; diff --git a/js/component/card/runtime_thermostat_detail.js b/js/component/card/runtime_thermostat_detail.js index 00036e0..eb1f0e0 100644 --- a/js/component/card/runtime_thermostat_detail.js +++ b/js/component/card/runtime_thermostat_detail.js @@ -44,9 +44,35 @@ beestat.extend(beestat.component.card.runtime_thermostat_detail, beestat.compone 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_thermostat_detail(data); - this.chart_.render(parent); + var range = { + 'type': beestat.setting('runtime_thermostat_detail_range_type'), + 'dynamic': beestat.setting('runtime_thermostat_detail_range_dynamic'), + 'static_begin': beestat.setting('runtime_thermostat_detail_range_static_begin'), + 'static_end': beestat.setting('runtime_thermostat_detail_range_static_end') + }; + + var data = beestat.runtime_thermostat.get_data(this.thermostat_id_, range); + + data.metadata.chart.title = this.get_title_(); + data.metadata.chart.subtitle = this.get_subtitle_(); + + this.charts_ = { + 'equipment': new beestat.component.chart.runtime_thermostat_detail_equipment(data), + 'temperature': new beestat.component.chart.runtime_thermostat_detail_temperature(data) + }; + + this.charts_.equipment.render(parent); + this.charts_.temperature.render(parent); + + // Sync extremes and crosshair. + Object.values(this.charts_).forEach(function(source_chart) { + Object.values(self.charts_).forEach(function(target_chart) { + if (source_chart !== target_chart) { + target_chart.sync_extremes(source_chart); + target_chart.sync_crosshair(source_chart); + } + }); + }); var thermostat = beestat.cache.thermostat[this.thermostat_id_]; @@ -90,8 +116,11 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = * the database, check every 2 seconds until it does. */ if (this.data_synced_(required_begin, required_end) === true) { - if (beestat.cache.runtime_thermostat === undefined) { - this.show_loading_('Loading Runtime Detail'); + if ( + beestat.cache.runtime_thermostat === undefined || + beestat.cache.data.runtime_thermostat_last !== 'runtime_thermostat_detail' + ) { + this.show_loading_('Loading Thermostat Detail'); var value; var operator; @@ -122,12 +151,13 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ = } ) .set_callback(function(response) { + beestat.cache.set('data.runtime_thermostat_last', 'runtime_thermostat_detail'); beestat.cache.set('runtime_thermostat', response); }) .send(); } } else { - this.show_loading_('Syncing Runtime Detail'); + this.show_loading_('Syncing Thermostat Detail'); setTimeout(function() { new beestat.api() .add_call( @@ -218,14 +248,14 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = .set_text('Download Chart') .set_icon('download') .set_callback(function() { - self.chart_.export(); + self.charts_.temperature.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(); + self.charts_.temperature.reset_zoom(); })); if (beestat.setting('runtime_thermostat_detail_smoothing') === true) { @@ -248,567 +278,17 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ = .set_text('Help') .set_icon('help_circle') .set_callback(function() { - window.open('https://www.notion.so/Runtime-Detail-e499fb13fd4441f4b3f096baca1cb138'); + window.open('https://doc.beestat.io/e499fb13fd4441f4b3f096baca1cb138'); })); }; -/** - * Get all of the series data. - * - * @return {object} The series data. - */ -beestat.component.card.runtime_thermostat_detail.prototype.get_data_ = function() { - 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); - } - }; - - // Duration objects. These are passed by reference into the metadata. - var durations = {}; - - // Y values for equipment swimlane data. - var equipment_y = { - 'calendar_event_smartrecovery': 94, - 'calendar_event_home': 94, - 'calendar_event_away': 94, - 'calendar_event_sleep': 94, - 'calendar_event_smarthome': 94, - 'calendar_event_smartaway': 94, - 'calendar_event_hold': 94, - 'calendar_event_vacation': 94, - 'calendar_event_quicksave': 94, - 'calendar_event_other': 94, - 'calendar_event_custom': 94, - 'compressor_heat_1': 67, - 'compressor_heat_2': 67, - 'auxiliary_heat_1': 67, - 'auxiliary_heat_2': 67, - 'compressor_cool_1': 67, - 'compressor_cool_2': 67, - 'fan': 47, - 'humidifier': 31, - 'dehumidifier': 31, - 'ventilator': 31, - 'economizer': 31 - }; - - // Initialize a bunch of stuff. - [ - 'calendar_event_smartrecovery', - 'calendar_event_home', - 'calendar_event_away', - 'calendar_event_sleep', - 'calendar_event_smarthome', - 'calendar_event_smartaway', - 'calendar_event_hold', - 'calendar_event_vacation', - 'calendar_event_quicksave', - 'calendar_event_other', - 'calendar_event_custom', - 'outdoor_temperature', - 'indoor_temperature', - 'indoor_humidity', - 'outdoor_humidity', - 'setpoint_heat', - 'setpoint_cool', - 'fan', - 'compressor_heat_1', - 'compressor_heat_2', - 'auxiliary_heat_1', - 'auxiliary_heat_2', - 'compressor_cool_1', - 'compressor_cool_2', - 'humidifier', - 'dehumidifier', - 'ventilator', - 'economizer', - 'dummy' - ].forEach(function(series_code) { - data.series[series_code] = []; - data.metadata.series[series_code] = { - 'active': false, - 'durations': {}, - - /** - * Note to future self: This can be used for all series. Need to - * populate the raw data points for each series here. The tooltip should - * get data from here and not the chart points array. Then the series - * data can be whatever is necessary to produce a performance-optimized - * chart as long as there is one series (dummy) that has a point at - * every x-value. That will allow a smooth tooltip, lightweight lines, - * and accurate data. - */ - 'data': {} - }; - durations[series_code] = {'seconds': 0}; - }); - - data.metadata.series.calendar_event_name = {}; - data.metadata.series.system_mode = {}; - - var begin_m; - var end_m; - if (beestat.setting('runtime_thermostat_detail_range_type') === 'dynamic') { - begin_m = moment().subtract( - beestat.setting('runtime_thermostat_detail_range_dynamic'), - 'day' - ); - end_m = moment().subtract(1, 'hour'); - } else { - begin_m = moment( - beestat.setting('runtime_thermostat_detail_range_static_begin') + ' 00:00:00' - ); - end_m = moment( - beestat.setting('runtime_thermostat_detail_range_static_end') + ' 23:59:59' - ); - } - - 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_thermostats = this.get_runtime_thermostat_by_date_(); - - // Initialize moving average. - var moving = []; - var moving_count; - if (beestat.setting('runtime_thermostat_detail_smoothing') === true) { - moving_count = 10; - } 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_thermostats[begin_m.valueOf() + offset]); - } - - // Loop. - var current_m = begin_m; - while ( - // beestat.cache.runtime_thermostat.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; - - var runtime_thermostat = runtime_thermostats[ - current_m.valueOf() - ]; - - if (runtime_thermostat !== undefined) { - /** - * Things that use the moving average. - */ - var indoor_humidity_moving = this.get_average_(moving, 'indoor_humidity'); - data.series.indoor_humidity.push(indoor_humidity_moving); - data.metadata.series.indoor_humidity.data[current_m.valueOf()] = - runtime_thermostat.indoor_humidity; - data.metadata.series.indoor_humidity.active = true; - - var outdoor_humidity_moving = this.get_average_(moving, 'outdoor_humidity'); - data.series.outdoor_humidity.push(outdoor_humidity_moving); - data.metadata.series.outdoor_humidity.data[current_m.valueOf()] = - runtime_thermostat.outdoor_humidity; - data.metadata.series.outdoor_humidity.active = true; - - var indoor_temperature_moving = beestat.temperature( - this.get_average_(moving, 'indoor_temperature') - ); - data.series.indoor_temperature.push(indoor_temperature_moving); - data.metadata.series.indoor_temperature.data[current_m.valueOf()] = - beestat.temperature(runtime_thermostat.indoor_temperature); - y_min_max(indoor_temperature_moving); - data.metadata.series.indoor_temperature.active = true; - - var outdoor_temperature_moving = beestat.temperature( - this.get_average_(moving, 'outdoor_temperature') - ); - data.series.outdoor_temperature.push(outdoor_temperature_moving); - data.metadata.series.outdoor_temperature.data[current_m.valueOf()] = - beestat.temperature(runtime_thermostat.outdoor_temperature); - y_min_max(outdoor_temperature_moving); - data.metadata.series.outdoor_temperature.active = true; - - /** - * Add setpoints, but only when relevant. For example: Only show the - * heat setpoint line when the heat is actually on. - */ - if ( - runtime_thermostat.system_mode === 'auto' || - runtime_thermostat.system_mode === 'heat' || - runtime_thermostat.system_mode === 'auxiliary_heat' - ) { - var setpoint_heat = beestat.temperature( - runtime_thermostat.setpoint_heat - ); - data.series.setpoint_heat.push(setpoint_heat); - y_min_max(setpoint_heat); - - data.metadata.series.setpoint_heat.active = true; - - } else { - data.series.setpoint_heat.push(null); - } - - if ( - runtime_thermostat.system_mode === 'auto' || - runtime_thermostat.system_mode === 'cool' - ) { - var setpoint_cool = beestat.temperature( - runtime_thermostat.setpoint_cool - ); - data.series.setpoint_cool.push(setpoint_cool); - y_min_max(setpoint_cool); - - data.metadata.series.setpoint_cool.active = true; - - } else { - data.series.setpoint_cool.push(null); - } - - /* - * HVAC Mode. This isn't graphed but it's available for the tooltip. - * series.system_mode.chart_data.push([x, runtime_thermostat.system_mode]); - */ - data.metadata.series.system_mode[current_m.valueOf()] = runtime_thermostat.system_mode; - - /* - * Thanks, ecobee...I more or less copied this code from the ecobee Follow - * Me graph to make sure it's accurate. - */ - var this_calendar_event; - var this_calendar_event_name; - - if (runtime_thermostat.event === null) { - if (runtime_thermostat.climate === null) { - this_calendar_event = 'calendar_event_other'; - this_calendar_event_name = 'Other'; - } else { - switch (runtime_thermostat.climate.toLowerCase()) { - case 'home': - case 'sleep': - case 'away': - this_calendar_event = 'calendar_event_' + runtime_thermostat.climate.toLowerCase(); - this_calendar_event_name = runtime_thermostat.climate; - break; - default: - this_calendar_event = 'calendar_event_custom'; - this_calendar_event_name = runtime_thermostat.climate; - break; - } - } - } else if (runtime_thermostat.event.match(/SmartRecovery/i) !== null) { - this_calendar_event = 'calendar_event_smartrecovery'; - this_calendar_event_name = 'Smart Recovery'; - } else if (runtime_thermostat.event.match(/^home$/i) !== null) { - this_calendar_event = 'calendar_event_home'; - this_calendar_event_name = 'Home'; - } else if (runtime_thermostat.event.match(/^away$/i) !== null) { - this_calendar_event = 'calendar_event_away'; - this_calendar_event_name = 'Away'; - } else if (runtime_thermostat.event.match(/^smarthome$/i) !== null) { - this_calendar_event = 'calendar_event_smarthome'; - this_calendar_event_name = 'Smart Home'; - } else if (runtime_thermostat.event.match(/^smartaway$/i) !== null) { - this_calendar_event = 'calendar_event_smartaway'; - this_calendar_event_name = 'Smart Away'; - } else if ( - runtime_thermostat.event.match(/^auto$/i) !== null || - runtime_thermostat.event.match(/^today$/i) !== null || - runtime_thermostat.event.match(/^hold$/i) !== null - ) { - this_calendar_event = 'calendar_event_hold'; - this_calendar_event_name = 'Hold'; - } else if ( - runtime_thermostat.event.match(/^vacation$/i) !== null || - runtime_thermostat.event.match(/(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i) !== null - ) { - this_calendar_event = 'calendar_event_vacation'; - this_calendar_event_name = 'Vacation'; - } else if (runtime_thermostat.event.match(/^quicksave$/i) !== null) { - this_calendar_event = 'calendar_event_quicksave'; - this_calendar_event_name = 'Quick Save'; - } else { - this_calendar_event = 'calendar_event_other'; - this_calendar_event_name = 'Other'; - } - - [ - 'calendar_event_smartrecovery', - 'calendar_event_home', - 'calendar_event_away', - 'calendar_event_sleep', - 'calendar_event_smarthome', - 'calendar_event_smartaway', - 'calendar_event_hold', - 'calendar_event_vacation', - 'calendar_event_quicksave', - 'calendar_event_other', - 'calendar_event_custom' - ].forEach(function(calendar_event) { - if (calendar_event === this_calendar_event) { - data.series[calendar_event].push(equipment_y[calendar_event]); - data.metadata.series[calendar_event].active = true; - } else { - data.series[calendar_event].push(null); - } - }); - - data.metadata.series.calendar_event_name[current_m.valueOf()] = - this_calendar_event_name; - - /** - * If all stages of the compressor are off, clear the durations. It is - * important that this only get reset if the seconds values are also - * zero to support backfilling. - */ - if ( - runtime_thermostat.compressor_1 === 0 && - runtime_thermostat.compressor_2 === 0 && - ( - durations.compressor_heat_1.seconds > 0 || - durations.compressor_heat_2.seconds > 0 || - durations.compressor_cool_1.seconds > 0 || - durations.compressor_cool_2.seconds > 0 - ) - ) { - durations.compressor_heat_1 = {'seconds': 0}; - durations.compressor_heat_2 = {'seconds': 0}; - durations.compressor_cool_1 = {'seconds': 0}; - durations.compressor_cool_2 = {'seconds': 0}; - } - - if ( - runtime_thermostat.auxiliary_heat_1 === 0 && - runtime_thermostat.auxiliary_heat_2 === 0 && - ( - durations.auxiliary_heat_1.seconds > 0 || - durations.auxiliary_heat_2.seconds > 0 - ) - ) { - durations.auxiliary_heat_1 = {'seconds': 0}; - durations.auxiliary_heat_2 = {'seconds': 0}; - } - - // Reset fan to 0 - if (runtime_thermostat.fan === 0) { - durations.fan = {'seconds': 0}; - } - - // Reset accessories - if (runtime_thermostat.accessory === 0) { - durations.humidifier = {'seconds': 0}; - durations.dehumidifier = {'seconds': 0}; - durations.ventilator = {'seconds': 0}; - durations.economizer = {'seconds': 0}; - } - - // Equipment - [ - 'fan', - 'compressor_heat_1', - 'compressor_heat_2', - 'auxiliary_heat_1', - 'auxiliary_heat_2', - 'compressor_cool_1', - 'compressor_cool_2', - 'humidifier', - 'dehumidifier', - 'ventilator', - 'economizer' - ].forEach(function(series_code) { - var runtime_thermostat_series_code; - switch (series_code) { - case 'compressor_heat_1': - case 'compressor_heat_2': - runtime_thermostat_series_code = series_code - .replace('compressor_heat', 'compressor'); - break; - case 'compressor_cool_1': - case 'compressor_cool_2': - runtime_thermostat_series_code = series_code - .replace('compressor_cool', 'compressor'); - break; - case 'humidifier': - case 'dehumidifier': - case 'ventilator': - case 'economizer': - runtime_thermostat_series_code = 'accessory'; - break; - default: - runtime_thermostat_series_code = series_code; - break; - } - - var equipment_on = function(series_code_on, runtime_thermostat_series_code_on) { - switch (series_code_on) { - case 'compressor_heat_1': - case 'compressor_heat_2': - return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && - runtime_thermostat.compressor_mode === 'heat'; - case 'compressor_cool_1': - case 'compressor_cool_2': - return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && - runtime_thermostat.compressor_mode === 'cool'; - case 'humidifier': - case 'dehumidifier': - case 'ventilator': - case 'economizer': - return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && - runtime_thermostat.accessory_type === series_code; - default: - return runtime_thermostat[series_code] > 0; - } - }; - - if (equipment_on(series_code, runtime_thermostat_series_code) === true) { - data.metadata.series[series_code].active = true; - data.metadata.series[series_code].durations[current_m.valueOf()] = durations[series_code]; - data.series[series_code].push(equipment_y[series_code]); - - if ( - series_code === 'auxiliary_heat_1' || - series_code === 'compressor_heat_1' || - series_code === 'compressor_cool_1' - ) { - var series_code_2 = series_code.replace('1', '2'); - data.metadata.series[series_code_2].durations[current_m.valueOf()] = durations[series_code_2]; - } - durations[series_code].seconds += runtime_thermostat[runtime_thermostat_series_code]; - - /* - * If heat/cool/aux 2 is on, extend the bar from heat/cool/aux 1 - * behind and set the duration. - */ - if (series_code.slice(-1) === '2') { - var series_code_1 = series_code.replace('2', '1'); - data.series[series_code_1] - .splice(-1, 1, equipment_y[series_code_1]); - data.metadata.series[series_code_1] - .durations[current_m.valueOf()] = durations[series_code_1]; - } - } else { - data.series[series_code].push(null); - } - }); - } else { - data.series.calendar_event_smartrecovery.push(null); - data.series.calendar_event_home.push(null); - data.series.calendar_event_away.push(null); - data.series.calendar_event_sleep.push(null); - data.series.calendar_event_smarthome.push(null); - data.series.calendar_event_smartaway.push(null); - data.series.calendar_event_hold.push(null); - data.series.calendar_event_vacation.push(null); - data.series.calendar_event_quicksave.push(null); - data.series.calendar_event_other.push(null); - data.series.calendar_event_custom.push(null); - data.series.indoor_temperature.push(null); - data.series.outdoor_temperature.push(null); - data.series.indoor_humidity.push(null); - data.series.outdoor_humidity.push(null); - data.series.setpoint_heat.push(null); - data.series.setpoint_cool.push(null); - data.series.fan.push(null); - data.series.compressor_heat_1.push(null); - data.series.compressor_heat_2.push(null); - data.series.auxiliary_heat_1.push(null); - data.series.auxiliary_heat_2.push(null); - data.series.compressor_cool_1.push(null); - data.series.compressor_cool_2.push(null); - data.series.humidifier.push(null); - data.series.dehumidifier.push(null); - data.series.ventilator.push(null); - data.series.economizer.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_thermostats[current_m.valueOf() + offset]); - } - - return data; -}; - -/** - * Get all the runtime_thermostat rows indexed by date. - * - * @return {array} The runtime_thermostat rows. - */ -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) { - runtime_thermostats[moment(runtime_thermostat.timestamp).valueOf()] = runtime_thermostat; - }); - } - return runtime_thermostats; -}; - -/** - * Given an array of runtime thermostats, 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_thermostats - * @param {string} series_code - * - * @return {number} The average. - */ -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++) { - if (runtime_thermostats[i] !== undefined) { - average += runtime_thermostats[i][series_code]; - count++; - } - } - - return average / count; -}; - /** * Get the title of the card. * * @return {string} Title */ beestat.component.card.runtime_thermostat_detail.prototype.get_title_ = function() { - return 'Runtime Detail'; + return 'Thermostat Detail'; }; /** @@ -849,7 +329,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.data_synced_ = functi return true; } - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; var current_sync_begin = moment.utc(thermostat.sync_begin); var current_sync_end = moment.utc(thermostat.sync_end); diff --git a/js/component/card/runtime_thermostat_summary.js b/js/component/card/runtime_thermostat_summary.js index ddabbb3..586ad1f 100755 --- a/js/component/card/runtime_thermostat_summary.js +++ b/js/component/card/runtime_thermostat_summary.js @@ -469,7 +469,7 @@ beestat.component.card.runtime_thermostat_summary.prototype.gap_fill_ = function * @return {string} The title. */ beestat.component.card.runtime_thermostat_summary.prototype.get_title_ = function() { - return 'Runtime Summary'; + return 'Thermostat Summary'; }; /** diff --git a/js/component/card/sensors.js b/js/component/card/sensors.js index a217d07..663216d 100644 --- a/js/component/card/sensors.js +++ b/js/component/card/sensors.js @@ -25,7 +25,7 @@ beestat.component.card.sensors.prototype.decorate_contents_ = function(parent) { var sensors = []; var internal_sensor; - $.values(beestat.cache.sensor).forEach(function(sensor) { + beestat.sensor.get_sorted().forEach(function(sensor) { if (sensor.thermostat_id === beestat.setting('thermostat_id')) { if (sensor.type === 'thermostat') { internal_sensor = sensor; @@ -35,10 +35,6 @@ beestat.component.card.sensors.prototype.decorate_contents_ = function(parent) { } }); - sensors.sort(function(a, b) { - return a.name.localeCompare(b.name, 'en', {'sensitivity': 'base'}); - }); - /* * Decorate the thermostat's internal sensor, if it has one. The Cor * thermostats, for example, do not. diff --git a/js/component/chart.js b/js/component/chart.js index dff6168..6c22225 100644 --- a/js/component/chart.js +++ b/js/component/chart.js @@ -61,6 +61,7 @@ beestat.component.chart.prototype.export = function() { */ beestat.component.chart.prototype.get_options_legend_ = function() { return { + 'enabled': this.get_options_legend_enabled_(), 'itemStyle': { 'color': '#ecf0f1', 'font-weight': '500' @@ -71,19 +72,7 @@ beestat.component.chart.prototype.get_options_legend_ = function() { 'itemHiddenStyle': { 'color': '#7f8c8d' }, - 'labelFormatter': this.get_options_legend_labelFormatter_(), - - - // 'layout': 'vertical', - // 'align': 'right', - // 'verticalAlign': 'top' - - // 'maxHeight': 1000, // To prevent the navigation thing - // 'floating': true, - // 'verticalAlign': 'top', - // 'y': 50, - // 'borderWidth': 1 - // + 'labelFormatter': this.get_options_legend_labelFormatter_() }; }; @@ -98,6 +87,15 @@ beestat.component.chart.prototype.get_options_legend_labelFormatter_ = function( }; }; +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.prototype.get_options_legend_enabled_ = function() { + return true; +}; + /** * Get the plotOptions. * @@ -172,12 +170,9 @@ beestat.component.chart.prototype.get_options_chart_ = function() { 'style': { 'fontFamily': 'Montserrat' }, - 'spacing': [ - beestat.style.size.gutter, - 0, - 0, - 0 - ], + 'spacing': this.get_options_chart_spacing_(), + // For consistent left spacing on charts with no y-axis values + 'marginLeft': this.get_options_chart_marginLeft_(), 'zoomType': this.get_options_chart_zoomType_(), 'panning': true, 'panKey': 'ctrl', @@ -192,6 +187,29 @@ beestat.component.chart.prototype.get_options_chart_ = function() { }; }; +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.prototype.get_options_chart_marginLeft_ = function() { + return undefined; +}; + +/** + * Get the spacing for the chart. + * + * @return {number} The spacing for the chart. + */ +beestat.component.chart.prototype.get_options_chart_spacing_ = function() { + return [ + (beestat.style.size.gutter / 2), + 0, + 0, + 0 + ]; +}; + /** * Get the events list for the chart. * @@ -201,6 +219,20 @@ beestat.component.chart.prototype.get_options_chart_events_ = function() { return null; }; +/** + * Get the spacing for the chart. + * + * @return {number} The spacing for the chart. + */ +// beestat.component.chart.prototype.get_options_chart_spacing_ = function() { +// return [ +// beestat.style.size.gutter, +// 0, +// 0, +// 0 +// ]; +// }; + /** * Get the height of the chart. * @@ -337,6 +369,8 @@ beestat.component.chart.prototype.get_options_credits_ = function() { * @return {object} The xAxis options. */ beestat.component.chart.prototype.get_options_xAxis_ = function() { + var self = this; + return { 'categories': this.data_.x, 'lineColor': beestat.style.color.bluegray.light, @@ -346,6 +380,14 @@ beestat.component.chart.prototype.get_options_xAxis_ = function() { 'color': beestat.style.color.gray.base }, 'formatter': this.get_options_xAxis_labels_formatter_() + }, + 'events': { + 'afterSetExtremes': function() { + // Make sure the extremes are set prior to firing the event. + // setTimeout(function() { + self.dispatchEvent('after_set_extremes'); + // }, 0) + } } }; }; @@ -419,28 +461,53 @@ beestat.component.chart.prototype.get_options_tooltip_formatter_ = function() { beestat.component.chart.prototype.get_options_tooltip_positioner_ = function() { var self = this; return function(tooltip_width, tooltip_height, point) { - var plot_width = self.chart_.plotWidth; - - var fits_on_left = (point.plotX - tooltip_width) > 0; - var fits_on_right = (point.plotX + tooltip_width) < plot_width; - - var x; - var y = 60; - if (fits_on_left === true) { - x = point.plotX - tooltip_width + self.chart_.plotLeft; - } else if (fits_on_right === true) { - x = point.plotX + self.chart_.plotLeft; - } else { - x = self.chart_.plotLeft; - } - return { - 'x': x, - 'y': y + 'x': self.get_options_tooltip_positioner_x_(tooltip_width, tooltip_height, point), + 'y': self.get_options_tooltip_positioner_y_(tooltip_width, tooltip_height, point) }; }; }; +/** + * Get the tooltip positioner x value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip x value. + */ +beestat.component.chart.prototype.get_options_tooltip_positioner_x_ = function(tooltip_width, tooltip_height, point) { + var plot_width = this.chart_.plotWidth; + + var fits_on_left = (point.plotX - tooltip_width) > 0; + var fits_on_right = (point.plotX + tooltip_width) < plot_width; + + var x; + if (fits_on_left === true) { + x = point.plotX - tooltip_width + this.chart_.plotLeft; + } else if (fits_on_right === true) { + x = point.plotX + this.chart_.plotLeft; + } else { + x = this.chart_.plotLeft; + } + + return x; +}; + +/** + * Get the tooltip positioner y value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip y value. + */ +beestat.component.chart.prototype.get_options_tooltip_positioner_y_ = function(tooltip_width, tooltip_height, point) { + return 60; +}; + /** * Get the HTML needed to render a tooltip. * @@ -506,3 +573,60 @@ beestat.component.chart.prototype.tooltip_formatter_helper_ = function(title, se return tooltip[0].outerHTML; }; + +/** + * Get the Highcharts chart object. + * + * @return {Highcharts} The Highcharts chart object. + */ +beestat.component.chart.prototype.get_chart = function() { + return this.chart_; +}; + +/** + * Sync extremes of this chart with extremes of another chart. + * + * @param {beestat.component.chart} source_chart The source chart. + */ +beestat.component.chart.prototype.sync_extremes = function(source_chart) { + var self = this; + + source_chart.addEventListener('after_set_extremes', function() { + var extremes = source_chart.get_chart().axes[0].getExtremes(); + self.get_chart().axes[0].setExtremes( + extremes.min, + extremes.max, + undefined, + false + ); + }); +}; + +/** + * Sync crosshair of this chart with crosshair of another chart. + * + * @param {beestat.component.chart} source_chart The source chart. + */ +beestat.component.chart.prototype.sync_crosshair = function(source_chart) { + var self = this; + + [ + 'mousemove', + 'touchmove', + 'touchstart' + ].forEach(function(event_type) { + source_chart.get_chart().container.addEventListener( + event_type, + function(e) { + var point = self.get_chart().series[0].searchPoint( + self.get_chart().pointer.normalize(e), + true + ); + if (point !== undefined) { + point.onMouseOver(); + point.series.chart.xAxis[0].drawCrosshair(event, this); + } + } + ); + }); +}; diff --git a/js/component/chart/runtime_sensor_detail.js b/js/component/chart/runtime_sensor_detail.js deleted file mode 100644 index 0a124ef..0000000 --- a/js/component/chart/runtime_sensor_detail.js +++ /dev/null @@ -1,361 +0,0 @@ -/** - * 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, - beestat.style.color.blue.light, - beestat.style.color.red.light, - beestat.style.color.yellow.light, - beestat.style.color.green.light, - beestat.style.color.orange.light, - beestat.style.color.bluegreen.light, - beestat.style.color.purple.light, - beestat.style.color.lightblue.light, - beestat.style.color.blue.dark, - beestat.style.color.red.dark, - beestat.style.color.yellow.dark, - beestat.style.color.green.dark, - beestat.style.color.orange.dark, - beestat.style.color.bluegreen.dark, - beestat.style.color.purple.dark, - beestat.style.color.lightblue.dark - ]; - - this.data_.metadata.sensors.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(self.data_.metadata.sensors.length, 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 y_min = Math.floor((this.data_.metadata.chart.y_min) / 5) * 5; - var y_max = Math.ceil((this.data_.metadata.chart.y_max) / 5) * 5; - - y_max += ((beestat.setting('temperature_unit') === '°F') ? 10 : 4); - - var tick_positions = []; - var tick_interval = (beestat.setting('temperature_unit') === '°F') ? 5 : 2; - 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': 0, - 'min': 0, - 'max': 100, - 'reversed': true, - 'gridLineWidth': 0, - 'title': {'text': null}, - 'labels': {'enabled': false}, - 'plotBands': { - 'zIndex': 2, - // 'color': 'red', - 'color': beestat.style.color.bluegray.dark, - 'from': 0, - 'to': 51 - } - } - ]; -}; - -/** - * 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 = []; - - // Get all the point values and index them by series_code for reference. - var values = {}; - this.points.forEach(function(point) { - values[point.series.name] = point.y; - }); - - /** - * Get a couple of other properties and index them by series_code for - * reference. This dives up to the chart itself because the tooltip shows - * all series unless explicitly disabled and those aren't always in the - * points array. - */ - var colors = {}; - var visible = {}; - self.chart_.series.forEach(function(series) { - colors[series.name] = series.color; - visible[series.name] = series.visible; - }); - - for (var series_code in self.data_.series) { - var label; - var value; - var color; - - if (series_code.includes('temperature') && visible[series_code] === true) { - label = self.data_.metadata.series[series_code].name; - color = colors[series_code]; - if (values[series_code] === undefined) { - value = '-'; - } else { - value = beestat.temperature({ - 'temperature': values[series_code], - 'convert': false, - 'units': true - }); - } - - var occupancy_key = series_code.replace('temperature', 'occupancy'); - if (values[occupancy_key] !== undefined && values[occupancy_key] !== null) { - value += ' ●'; - } - - 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) { - // Available height for all swimlanes - var height = 50; - - // Some sensible max height if you have very few sensors. - var max_line_width = 16; - - // Spacing. This is arbitrary...spacing decreases to 0 after you hit 15 sensors. - var spacing = Math.floor(15 / count); - - // Base line width is a percentage height of the container. - var line_width = Math.floor(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; - - // Center within the swimlane area. - var occupied_space = (line_width * count) + (spacing * count); - var empty_space = height - occupied_space; - // y += (empty_space / 2); - - return { - 'line_width': line_width, - 'y': y - }; -}; - -/** - * 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...I simply overlay the swimlanes on the top of a - * full-height temperature chart. Then I draw a rectangle on top of y-axis - * labels I want to hide so it appears to be on it's own. - * - * Helpful: https://www.highcharts.com/demo/renderer - * - * @return {object} The events list for the chart. - */ -beestat.component.chart.runtime_sensor_detail.prototype.get_options_chart_events_ = function() { - return { - 'load': function() { - this.renderer.rect(0, 0, 30, 80) - .attr({ - 'fill': beestat.style.color.bluegray.base, - 'zIndex': 10 - }) - .add(); - } - }; -}; - -/** - * See comment on get_options_chart_events_. This is done separately to - * override the normal load event rectangle draw because on export I also add - * padding and a title which screws up the positioning a bit. - * - * @return {object} The events list for the chart on export. - */ -beestat.component.chart.runtime_sensor_detail.prototype.get_options_exporting_chart_events_ = function() { - return { - 'load': function() { - this.renderer.rect(beestat.style.size.gutter, 60, 30, 60) - .attr({ - 'fill': beestat.style.color.bluegray.base, - 'zIndex': 10 - }) - .add(); - } - }; -}; diff --git a/js/component/chart/runtime_sensor_detail_occupancy.js b/js/component/chart/runtime_sensor_detail_occupancy.js new file mode 100644 index 0000000..69d786d --- /dev/null +++ b/js/component/chart/runtime_sensor_detail_occupancy.js @@ -0,0 +1,188 @@ +/** + * Runtime sensor detail chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.runtime_sensor_detail_occupancy = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.runtime_sensor_detail_occupancy, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_xAxis_labels_formatter_ = function() { + return function() { + return null; + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.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, + beestat.style.color.blue.light, + beestat.style.color.red.light, + beestat.style.color.yellow.light, + beestat.style.color.green.light, + beestat.style.color.orange.light, + beestat.style.color.bluegreen.light, + beestat.style.color.purple.light, + beestat.style.color.lightblue.light, + beestat.style.color.blue.dark, + beestat.style.color.red.dark, + beestat.style.color.yellow.dark, + beestat.style.color.green.dark, + beestat.style.color.orange.dark, + beestat.style.color.bluegreen.dark, + beestat.style.color.purple.dark, + beestat.style.color.lightblue.dark + ]; + + // Sensors + this.data_.metadata.sensors.forEach(function(sensor, i) { + series.push({ + 'name': 'occupancy_' + sensor.sensor_id, + 'data': self.data_.series['occupancy_' + sensor.sensor_id], + 'color': colors[i], + 'yAxis': 0, + 'type': 'line', + 'lineWidth': beestat.component.chart.runtime_sensor_detail_occupancy.get_swimlane_properties(self.data_.metadata.sensors.length, 1).line_width, + 'linecap': 'square', + 'className': 'crisp_edges' + }); + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': 0 + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_yAxis_ = function() { + return [ + { + 'min': 0, + 'max': 50, + + // Keeps the chart from ending on a multiple of whatever the tick interval gets set to. + 'endOnTick': false, + + // 'min': 0, + // 'minRange': 100, + // 'ceiling': 100 + + 'reversed': true, + 'gridLineWidth': 0, + 'title': {'text': null}, + 'labels': {'enabled': false}, + 'plotBands': [ + { + 'zIndex': 2, + 'color': beestat.style.color.bluegray.dark, + 'from': 0, + 'to': 50 + } + ] + } + ]; +}; + +/** + * 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_occupancy.get_swimlane_properties = function(count, i) { + // Available height for all swimlanes + var height = 50; + + // Some sensible max height if you have very few sensors. + var max_line_width = 16; + + // Spacing. This is arbitrary...spacing decreases to 0 after you hit 15 sensors. + var spacing = Math.floor(15 / count); + spacing = Math.min(spacing, 4); + + // Base line width is a percentage height of the container. + var line_width = Math.floor(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; + + // Center within the swimlane area. + // var occupied_space = (line_width * count) + (spacing * count); + // var empty_space = height - occupied_space; + // y += (empty_space / 2); + + return { + 'line_width': line_width, + 'y': y + }; +}; + +/** + * Get the height of the chart. For really precise charts, make sure to + * include relevant spacing. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_chart_height_ = function() { + return 50 + this.get_options_chart_spacing_()[0] + this.get_options_chart_spacing_()[2]; +}; + +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_legend_enabled_ = function() { + return false; +}; + +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.runtime_sensor_detail_occupancy.prototype.get_options_chart_marginLeft_ = function() { + return 40; +}; diff --git a/js/component/chart/runtime_sensor_detail_temperature.js b/js/component/chart/runtime_sensor_detail_temperature.js new file mode 100644 index 0000000..9e46a64 --- /dev/null +++ b/js/component/chart/runtime_sensor_detail_temperature.js @@ -0,0 +1,244 @@ +/** + * Runtime sensor detail chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.runtime_sensor_detail_temperature = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.runtime_sensor_detail_temperature, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.runtime_sensor_detail_temperature.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_temperature.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_temperature.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, + beestat.style.color.blue.light, + beestat.style.color.red.light, + beestat.style.color.yellow.light, + beestat.style.color.green.light, + beestat.style.color.orange.light, + beestat.style.color.bluegreen.light, + beestat.style.color.purple.light, + beestat.style.color.lightblue.light, + beestat.style.color.blue.dark, + beestat.style.color.red.dark, + beestat.style.color.yellow.dark, + beestat.style.color.green.dark, + beestat.style.color.orange.dark, + beestat.style.color.bluegreen.dark, + beestat.style.color.purple.dark, + beestat.style.color.lightblue.dark + ]; + + // Sensors + this.data_.metadata.sensors.forEach(function(sensor, i) { + series.push({ + 'name': 'temperature_' + sensor.sensor_id, + 'data': self.data_.series['temperature_' + sensor.sensor_id], + 'color': colors[i], + 'yAxis': 0, + 'type': 'spline', + 'lineWidth': 1, + 'events': { + 'legendItemClick': function() { + // Delay the event dispatch so the series is actually toggled to the correct visibility. + setTimeout(function() { + self.dispatchEvent('legend_item_click'); + }, 0); + } + } + }); + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 0, + '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_temperature.prototype.get_options_yAxis_ = function() { + return [ + { + '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'); + } + } + } + ]; +}; + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var sections = []; + var group = []; + + // Get all the point values and index them by series_code for reference. + var values = {}; + this.points.forEach(function(point) { + values[point.series.name] = point.y; + + var occupancy_key = point.series.name.replace('temperature', 'occupancy'); + if (self.data_.metadata.series[occupancy_key] !== undefined) { + values[occupancy_key] = + self.data_.metadata.series[occupancy_key].data[point.x.valueOf()]; + } + }); + + /** + * Get a couple of other properties and index them by series_code for + * reference. This dives up to the chart itself because the tooltip shows + * all series unless explicitly disabled and those aren't always in the + * points array. + */ + var colors = {}; + var visible = {}; + self.chart_.series.forEach(function(series) { + colors[series.name] = series.color; + visible[series.name] = series.visible; + }); + + for (var series_code in self.data_.series) { + var label; + var value; + var color; + + if (visible[series_code] === true) { + label = self.data_.metadata.series[series_code].name; + color = colors[series_code]; + if (values[series_code] === undefined) { + value = '-'; + } else { + value = beestat.temperature({ + 'temperature': values[series_code], + 'convert': false, + 'units': true + }); + } + + var occupancy_key = series_code.replace('temperature', 'occupancy'); + if (values[occupancy_key] !== undefined && values[occupancy_key] !== null) { + value += ' ●'; + } + + 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 the tooltip positioner y value. + * + * @param {number} tooltip_width Tooltip width. + * @param {number} tooltip_height Tooltip height. + * @param {point} point Highcharts current point. + * + * @return {number} The tooltip y value. + */ +beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_tooltip_positioner_y_ = function() { + return 0; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_chart_height_ = function() { + return 300; +}; diff --git a/js/component/chart/runtime_thermostat_detail_equipment.js b/js/component/chart/runtime_thermostat_detail_equipment.js new file mode 100644 index 0000000..13d0cff --- /dev/null +++ b/js/component/chart/runtime_thermostat_detail_equipment.js @@ -0,0 +1,131 @@ +/** + * Runtime sensor detail chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.runtime_thermostat_detail_equipment = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.runtime_thermostat_detail_equipment, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_xAxis_labels_formatter_ = function() { + return function() { + return null; + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'fan', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].active === true) { + var line_width; + if ( + series_code.includes('heat') === true || + series_code.includes('cool') === true + ) { + line_width = 12; + } else { + line_width = 6; + } + + series.push({ + 'name': series_code, + 'data': self.data_.series[series_code], + 'color': beestat.series[series_code].color, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': line_width, + 'linecap': 'square', + 'className': 'crisp_edges' + }); + } + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_yAxis_ = function() { + return [ + { + 'min': 0, + 'max': 44, + + // Keeps the chart from ending on a multiple of whatever the tick interval gets set to. + 'endOnTick': false, + + 'reversed': true, + 'gridLineWidth': 0, + 'title': {'text': null}, + 'labels': {'enabled': false} + } + ]; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_chart_height_ = function() { + return 44; +}; + +/** + * Get the legend enabled options. + * + * @return {Function} The legend enabled options. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_legend_enabled_ = function() { + return false; +}; + +/** + * Get the left margin for the chart. + * + * @return {number} The left margin for the chart. + */ +beestat.component.chart.runtime_thermostat_detail_equipment.prototype.get_options_chart_marginLeft_ = function() { + return 40; +}; diff --git a/js/component/chart/runtime_thermostat_detail.js b/js/component/chart/runtime_thermostat_detail_temperature.js similarity index 82% rename from js/component/chart/runtime_thermostat_detail.js rename to js/component/chart/runtime_thermostat_detail_temperature.js index 6901b51..2236ea7 100644 --- a/js/component/chart/runtime_thermostat_detail.js +++ b/js/component/chart/runtime_thermostat_detail_temperature.js @@ -3,19 +3,19 @@ * * @param {object} data The chart data. */ -beestat.component.chart.runtime_thermostat_detail = function(data) { +beestat.component.chart.runtime_thermostat_detail_temperature = function(data) { this.data_ = data; beestat.component.chart.apply(this, arguments); }; -beestat.extend(beestat.component.chart.runtime_thermostat_detail, beestat.component.chart); +beestat.extend(beestat.component.chart.runtime_thermostat_detail_temperature, beestat.component.chart); /** * Override for get_options_xAxis_labels_formatter_. * * @return {Function} xAxis labels formatter. */ -beestat.component.chart.runtime_thermostat_detail.prototype.get_options_xAxis_labels_formatter_ = function() { +beestat.component.chart.runtime_thermostat_detail_temperature.prototype.get_options_xAxis_labels_formatter_ = function() { var current_day; var current_hour; @@ -43,7 +43,7 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_xAxis_la * * @return {Array} All of the series to display on the chart. */ -beestat.component.chart.runtime_thermostat_detail.prototype.get_options_series_ = function() { +beestat.component.chart.runtime_thermostat_detail_temperature.prototype.get_options_series_ = function() { var self = this; var series = []; @@ -103,60 +103,10 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_series_ } }); - // Swimlanes - [ - 'calendar_event_smartrecovery', - 'calendar_event_home', - 'calendar_event_away', - 'calendar_event_sleep', - 'calendar_event_smarthome', - 'calendar_event_smartaway', - 'calendar_event_hold', - 'calendar_event_vacation', - 'calendar_event_quicksave', - 'calendar_event_other', - 'calendar_event_custom', - 'compressor_heat_1', - 'compressor_heat_2', - 'auxiliary_heat_1', - 'auxiliary_heat_2', - 'compressor_cool_1', - 'compressor_cool_2', - 'fan', - 'humidifier', - 'dehumidifier', - 'ventilator', - 'economizer' - ].forEach(function(series_code) { - if (self.data_.metadata.series[series_code].active === true) { - var line_width; - if ( - series_code.includes('heat') === true || - series_code.includes('cool') === true - ) { - line_width = 12; - } else { - line_width = 6; - } - - series.push({ - 'name': series_code, - 'data': self.data_.series[series_code], - 'color': beestat.series[series_code].color, - 'yAxis': 2, - 'type': 'line', - 'lineWidth': line_width, - 'linecap': 'square', - 'className': 'crisp_edges', - 'showInLegend': false - }); - } - }); - series.push({ 'name': '', 'data': self.data_.series.dummy, - 'yAxis': 2, + 'yAxis': 0, 'type': 'line', 'lineWidth': 0, 'showInLegend': false @@ -170,7 +120,7 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_series_ * * @return {Array} The y-axis options. */ -beestat.component.chart.runtime_thermostat_detail.prototype.get_options_yAxis_ = function() { +beestat.component.chart.runtime_thermostat_detail_temperature.prototype.get_options_yAxis_ = function() { /** * Highcharts doesn't seem to respect axis behavior well so just overriding * it completely here. @@ -190,8 +140,6 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_yAxis_ = return [ // Temperature { - 'height': '80%', - 'top': '20%', 'gridLineColor': beestat.style.color.bluegray.light, 'gridLineDashStyle': 'longdash', 'title': {'text': null}, @@ -206,8 +154,6 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_yAxis_ = // Humidity { - 'height': '80%', - 'top': '20%', 'alignTicks': false, 'gridLineColor': null, 'opposite': true, @@ -223,32 +169,21 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_yAxis_ = 'min': 0, 'minRange': 100, 'ceiling': 100 - }, - - // Swimlanes - { - 'height': '20%', - 'top': '0%', - 'min': 0, - 'max': 100, - 'gridLineWidth': 0, - 'title': {'text': null}, - 'labels': {'enabled': false} } ]; }; -// https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/stock/demo/candlestick-and-volume/ - /** * Override for get_options_tooltip_formatter_. * * @return {Function} The tooltip formatter. */ -beestat.component.chart.runtime_thermostat_detail.prototype.get_options_tooltip_formatter_ = function() { +beestat.component.chart.runtime_thermostat_detail_temperature.prototype.get_options_tooltip_formatter_ = function() { var self = this; return function() { + var self2 = this; + var sections = []; var groups = { 'mode': [], @@ -256,6 +191,43 @@ beestat.component.chart.runtime_thermostat_detail.prototype.get_options_tooltip_ 'equipment': [] }; + // Add a bunch of fake points so they appear in the tooltip. + [ + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'fan', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer', + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].data[self2.x.valueOf()] !== undefined) { + self2.points.push({ + 'series': { + 'name': series_code, + 'color': beestat.series[series_code].color + }, + 'x': self2.x, + 'y': self.data_.metadata.series[series_code].data[self2.x.valueOf()] + }); + } + }); + var values = {}; this.points.forEach(function(point) { values[point.series.name] = point.y; diff --git a/js/component/header.js b/js/component/header.js index 76e1b74..46b1c20 100644 --- a/js/component/header.js +++ b/js/component/header.js @@ -26,38 +26,23 @@ beestat.component.header.prototype.decorate_ = function(parent) { var pages; - if (beestat.user.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' - } - ]; - } + pages = [ + { + 'layer': 'dashboard', + 'text': 'Dashboard', + 'icon': 'tablet_dashboard' + }, + { + 'layer': 'sensors', + 'text': 'Sensors', + 'icon': 'signal_variant' + }, + { + 'layer': 'home_comparisons', + 'text': 'Comparisons', + 'icon': 'home_group' + } + ]; var gutter = beestat.style.size.gutter; diff --git a/js/component/modal/runtime_sensor_detail_custom.js b/js/component/modal/runtime_sensor_detail_custom.js index e69de29..1cdaeb2 100644 --- a/js/component/modal/runtime_sensor_detail_custom.js +++ b/js/component/modal/runtime_sensor_detail_custom.js @@ -0,0 +1,374 @@ +/** + * Custom date range for the Runtime Detail chart. + */ +beestat.component.modal.runtime_sensor_detail_custom = function() { + beestat.component.modal.apply(this, arguments); + this.state_.runtime_sensor_detail_range_type = beestat.setting('runtime_sensor_detail_range_type'); + this.state_.runtime_sensor_detail_range_dynamic = beestat.setting('runtime_sensor_detail_range_dynamic'); + this.state_.runtime_sensor_detail_range_static_begin = beestat.setting('runtime_sensor_detail_range_static_begin'); + this.state_.runtime_sensor_detail_range_static_end = beestat.setting('runtime_sensor_detail_range_static_end'); + this.state_.error = { + 'max_range': false, + 'invalid_range_begin': false, + 'invalid_range_end': false, + 'out_of_sync_range': false + }; +}; +beestat.extend(beestat.component.modal.runtime_sensor_detail_custom, beestat.component.modal); + +/** + * Decorate. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Sensor Detail chart. Max range is 7 days at a time and 30 days in the past. This limit will be raised in the future.')); + + this.decorate_range_type_(parent); + + if (this.state_.runtime_sensor_detail_range_type === 'dynamic') { + this.decorate_range_dynamic_(parent); + } else { + this.decorate_range_static_(parent); + } + + this.decorate_error_(parent); +}; + +/** + * Decorate the range type selector. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.decorate_range_type_ = function(parent) { + var self = this; + + var button_group = new beestat.component.button_group(); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.runtime_sensor_detail_range_type === 'dynamic' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Dynamic') + .addEventListener('click', function() { + self.state_.runtime_sensor_detail_range_type = 'dynamic'; + self.rerender(); + })); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.runtime_sensor_detail_range_type === 'static' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Static') + .addEventListener('click', function() { + self.state_.runtime_sensor_detail_range_type = 'static'; + self.rerender(); + })); + + (new beestat.component.title('Range Type')).render(parent); + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + button_group.render(column); +}; + +/** + * Decorate the static range inputs. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.decorate_range_static_ = function(parent) { + var self = this; + + var runtime_sensor_detail_static_range_begin; + var runtime_sensor_detail_static_range_end; + + /** + * Check whether or not a value is outside of where data is synced. + */ + var check_out_of_sync_range = function() { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var min = moment.max( + moment(thermostat.sync_begin), + moment().subtract(1, 'month') + ); + var max = moment(thermostat.sync_end); + + var begin = moment.min( + moment(runtime_sensor_detail_static_range_begin.get_value()), + moment(runtime_sensor_detail_static_range_end.get_value()) + ); + + var end = moment.max( + moment(runtime_sensor_detail_static_range_begin.get_value() + ' 00:00:00'), + moment(runtime_sensor_detail_static_range_end.get_value() + ' 23:59:59') + ); + + if ( + begin.isBefore(min) === true || + end.isAfter(max) === true + ) { + self.state_.error.out_of_sync_range = true; + } else { + self.state_.error.out_of_sync_range = false; + } + }; + + runtime_sensor_detail_static_range_begin = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.runtime_sensor_detail_range_static_begin); + + runtime_sensor_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_sensor_detail_static_range_end.get_value()), 'day')) + 1; + if (diff > 7) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.runtime_sensor_detail_range_static_begin = value; + self.rerender(); + } else { + self.state_.runtime_sensor_detail_range_static_begin = this.get_value(); + self.state_.error.invalid_range_begin = true; + self.rerender(); + } + }); + + runtime_sensor_detail_static_range_end = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.runtime_sensor_detail_range_static_end); + + runtime_sensor_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_sensor_detail_static_range_begin.get_value()), 'day')) + 1; + if (diff > 7) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.runtime_sensor_detail_range_static_end = value; + self.rerender(); + } else { + self.state_.runtime_sensor_detail_range_static_end = this.get_value(); + self.state_.error.invalid_range_end = true; + self.rerender(); + } + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_sensor_detail_static_range_begin.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter, + 'margin-right': beestat.style.size.gutter + }) + .innerText('to'); + column.appendChild(span); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_sensor_detail_static_range_end.render(span); + column.appendChild(span); +}; + +/** + * Decorate the dynamic range input. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.decorate_range_dynamic_ = function(parent) { + var self = this; + + var runtime_sensor_detail_range_dynamic = new beestat.component.input.text() + .set_style({ + 'width': 75, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 1 + }) + .set_icon('pound') + .set_value(beestat.setting('runtime_sensor_detail_range_dynamic')); + + runtime_sensor_detail_range_dynamic.addEventListener('blur', function() { + var value = parseInt(this.get_value(), 10); + if (isNaN(value) === true || value === 0) { + value = 1; + } else if (value > 7) { + value = 7; + } + this.set_value(value); + self.state_.runtime_sensor_detail_range_dynamic = value; + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_sensor_detail_range_dynamic.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter + }) + .innerText('days'); + column.appendChild(span); +}; + +/** + * Decorate the error area. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_sensor_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 7 days.')); + } + if (this.state_.error.invalid_range_begin === true) { + div.appendChild($.createElement('div').innerText('Invalid begin date.')); + } + if (this.state_.error.invalid_range_end === true) { + div.appendChild($.createElement('div').innerText('Invalid end date.')); + } + if (this.state_.error.out_of_sync_range === true) { + div.appendChild($.createElement('div').innerText('Detail not available for this range.')); + } + parent.appendChild(div); +}; + +/** + * Get title. + * + * @return {string} Title + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.get_title_ = function() { + return 'Sensor Detail - Custom Range'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.runtime_sensor_detail_custom.prototype.get_buttons_ = function() { + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save; + if ( + this.state_.error.max_range === true || + this.state_.error.invalid_range_begin === true || + this.state_.error.invalid_range_end === true || + this.state_.error.out_of_sync_range === true + ) { + save = new beestat.component.button() + .set_background_color(beestat.style.color.gray.base) + .set_text_color('#fff') + .set_text('Save'); + } else { + save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Save') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .removeEventListener('click'); + + if (moment(self.state_.runtime_sensor_detail_range_static_begin).isAfter(moment(self.state_.runtime_sensor_detail_range_static_end)) === true) { + var temp = self.state_.runtime_sensor_detail_range_static_begin; + self.state_.runtime_sensor_detail_range_static_begin = self.state_.runtime_sensor_detail_range_static_end; + self.state_.runtime_sensor_detail_range_static_end = temp; + } + + beestat.cache.delete('runtime_thermostat'); + beestat.cache.delete('runtime_sensor'); + beestat.setting( + { + 'runtime_sensor_detail_range_type': self.state_.runtime_sensor_detail_range_type, + 'runtime_sensor_detail_range_dynamic': self.state_.runtime_sensor_detail_range_dynamic, + 'runtime_sensor_detail_range_static_begin': self.state_.runtime_sensor_detail_range_static_begin, + 'runtime_sensor_detail_range_static_end': self.state_.runtime_sensor_detail_range_static_end + }, + undefined, + function() { + self.dispose(); + } + ); + }); + } + + return [ + cancel, + save + ]; +}; diff --git a/js/component/modal/runtime_thermostat_detail_custom.js b/js/component/modal/runtime_thermostat_detail_custom.js index 0203c4b..d3e1d34 100644 --- a/js/component/modal/runtime_thermostat_detail_custom.js +++ b/js/component/modal/runtime_thermostat_detail_custom.js @@ -22,7 +22,7 @@ beestat.extend(beestat.component.modal.runtime_thermostat_detail_custom, beestat * @param {rocket.Elements} 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.')); + parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Thermostat Detail chart.')); this.decorate_range_type_(parent); @@ -301,7 +301,7 @@ beestat.component.modal.runtime_thermostat_detail_custom.prototype.decorate_erro * @return {string} Title */ beestat.component.modal.runtime_thermostat_detail_custom.prototype.get_title_ = function() { - return 'Runtime Detail - Custom Range'; + return 'Thermostat Detail - Custom Range'; }; /** diff --git a/js/component/modal/runtime_thermostat_summary_custom.js b/js/component/modal/runtime_thermostat_summary_custom.js index 2a30ad6..05e0d33 100644 --- a/js/component/modal/runtime_thermostat_summary_custom.js +++ b/js/component/modal/runtime_thermostat_summary_custom.js @@ -143,7 +143,7 @@ beestat.component.modal.runtime_thermostat_summary_custom.prototype.decorate_con * @return {string} Title */ beestat.component.modal.runtime_thermostat_summary_custom.prototype.get_title_ = function() { - return 'Runtime Summary - Custom Range'; + return 'Thermostat Summary - Custom Range'; }; /** diff --git a/js/js.php b/js/js.php index 11a964a..d4dbcc5 100755 --- a/js/js.php +++ b/js/js.php @@ -32,6 +32,9 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; // Layer echo '' . PHP_EOL; @@ -65,8 +68,10 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; - echo '' . PHP_EOL; - echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL;