diff --git a/CODEOWNERS b/CODEOWNERS index 8aa96d14af..ab3a0815ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ esphome/components/fingerprint_grow/* @OnFreund @loongyh esphome/components/globals/* @esphome/core esphome/components/gpio/* @esphome/core esphome/components/gps/* @coogle +esphome/components/graph/* @synco esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 9c4ee3189f..bbb7444cb5 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -233,6 +233,13 @@ void DisplayBuffer::image(int x, int y, Image *image, Color color_on, Color colo } } +#ifdef USE_GRAPH +void DisplayBuffer::graph(int x, int y, graph::Graph *graph, Color color_on) { graph->draw(this, x, y, color_on); } +void DisplayBuffer::legend(int x, int y, graph::Graph *graph, Color color_on) { + graph->draw_legend(this, x, y, color_on); +} +#endif // USE_GRAPH + void DisplayBuffer::get_text_bounds(int x, int y, const char *text, Font *font, TextAlign align, int *x1, int *y1, int *width, int *height) { int x_offset, baseline; diff --git a/esphome/components/display/display_buffer.h b/esphome/components/display/display_buffer.h index b89eab0dba..3f89d3f8d2 100644 --- a/esphome/components/display/display_buffer.h +++ b/esphome/components/display/display_buffer.h @@ -9,6 +9,10 @@ #include "esphome/components/time/real_time_clock.h" #endif +#ifdef USE_GRAPH +#include "esphome/components/graph/graph.h" +#endif + namespace esphome { namespace display { @@ -273,6 +277,30 @@ class DisplayBuffer { */ void image(int x, int y, Image *image, Color color_on = COLOR_ON, Color color_off = COLOR_OFF); +#ifdef USE_GRAPH + /** Draw the `graph` with the top-left corner at [x,y] to the screen. + * + * @param x The x coordinate of the upper left corner. + * @param y The y coordinate of the upper left corner. + * @param graph The graph id to draw + * @param color_on The color to replace in binary images for the on bits. + */ + void graph(int x, int y, graph::Graph *graph, Color color_on = COLOR_ON); + + /** Draw the `legend` for graph with the top-left corner at [x,y] to the screen. + * + * @param x The x coordinate of the upper left corner. + * @param y The y coordinate of the upper left corner. + * @param graph The graph id for which the legend applies to + * @param graph The graph id for which the legend applies to + * @param graph The graph id for which the legend applies to + * @param name_font The font used for the trace name + * @param value_font The font used for the trace value and units + * @param color_on The color of the border + */ + void legend(int x, int y, graph::Graph *graph, Color color_on = COLOR_ON); +#endif // USE_GRAPH + /** Get the text bounds of the given string. * * @param x The x coordinate to place the string at, can be 0 if only interested in dimensions. diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py new file mode 100644 index 0000000000..12acfee869 --- /dev/null +++ b/esphome/components/graph/__init__.py @@ -0,0 +1,216 @@ +from esphome.components.font import Font +from esphome.components import sensor, color +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_COLOR, + CONF_DIRECTION, + CONF_DURATION, + CONF_ID, + CONF_LEGEND, + CONF_NAME, + CONF_NAME_FONT, + CONF_SHOW_LINES, + CONF_SHOW_UNITS, + CONF_SHOW_VALUES, + CONF_VALUE_FONT, + CONF_WIDTH, + CONF_SENSOR, + CONF_HEIGHT, + CONF_MIN_VALUE, + CONF_MAX_VALUE, + CONF_MIN_RANGE, + CONF_MAX_RANGE, + CONF_LINE_THICKNESS, + CONF_LINE_TYPE, + CONF_X_GRID, + CONF_Y_GRID, + CONF_BORDER, + CONF_TRACES, +) + +CODEOWNERS = ["@synco"] + +DEPENDENCIES = ["display", "sensor"] +MULTI_CONF = True + +graph_ns = cg.esphome_ns.namespace("graph") +Graph_ = graph_ns.class_("Graph", cg.Component) +GraphTrace = graph_ns.class_("GraphTrace") +GraphLegend = graph_ns.class_("GraphLegend") + +LineType = graph_ns.enum("LineType") +LINE_TYPE = { + "SOLID": LineType.LINE_TYPE_SOLID, + "DOTTED": LineType.LINE_TYPE_DOTTED, + "DASHED": LineType.LINE_TYPE_DASHED, +} + +DirectionType = graph_ns.enum("DirectionType") +DIRECTION_TYPE = { + "AUTO": DirectionType.DIRECTION_TYPE_AUTO, + "HORIZONTAL": DirectionType.DIRECTION_TYPE_HORIZONTAL, + "VERTICAL": DirectionType.DIRECTION_TYPE_VERTICAL, +} + +ValuePositionType = graph_ns.enum("ValuePositionType") +VALUE_POSITION_TYPE = { + "NONE": ValuePositionType.VALUE_POSITION_TYPE_NONE, + "AUTO": ValuePositionType.VALUE_POSITION_TYPE_AUTO, + "BESIDE": ValuePositionType.VALUE_POSITION_TYPE_BESIDE, + "BELOW": ValuePositionType.VALUE_POSITION_TYPE_BELOW, +} + + +GRAPH_TRACE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GraphTrace), + cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_LINE_THICKNESS): cv.positive_int, + cv.Optional(CONF_LINE_TYPE): cv.enum(LINE_TYPE, upper=True), + cv.Optional(CONF_COLOR): cv.use_id(color.ColorStruct), + } +) + +GRAPH_LEGEND_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(GraphLegend), + cv.Required(CONF_NAME_FONT): cv.use_id(Font), + cv.Optional(CONF_VALUE_FONT): cv.use_id(Font), + cv.Optional(CONF_WIDTH): cv.positive_not_null_int, + cv.Optional(CONF_HEIGHT): cv.positive_not_null_int, + cv.Optional(CONF_BORDER): cv.boolean, + cv.Optional(CONF_SHOW_LINES): cv.boolean, + cv.Optional(CONF_SHOW_VALUES): cv.enum(VALUE_POSITION_TYPE, upper=True), + cv.Optional(CONF_SHOW_UNITS): cv.boolean, + cv.Optional(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), + } +) + + +GRAPH_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(Graph_), + cv.Required(CONF_DURATION): cv.positive_time_period_seconds, + cv.Required(CONF_WIDTH): cv.positive_not_null_int, + cv.Required(CONF_HEIGHT): cv.positive_not_null_int, + cv.Optional(CONF_X_GRID): cv.positive_time_period_seconds, + cv.Optional(CONF_Y_GRID): cv.float_range(min=0, min_included=False), + cv.Optional(CONF_BORDER): cv.boolean, + # Single trace options in base + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_LINE_THICKNESS): cv.positive_int, + cv.Optional(CONF_LINE_TYPE): cv.enum(LINE_TYPE, upper=True), + cv.Optional(CONF_COLOR): cv.use_id(color.ColorStruct), + # Axis specific options (Future feature may be to add second Y-axis) + cv.Optional(CONF_MIN_VALUE): cv.float_, + cv.Optional(CONF_MAX_VALUE): cv.float_, + cv.Optional(CONF_MIN_RANGE): cv.float_range(min=0, min_included=False), + cv.Optional(CONF_MAX_RANGE): cv.float_range(min=0, min_included=False), + cv.Optional(CONF_TRACES): cv.ensure_list(GRAPH_TRACE_SCHEMA), + cv.Optional(CONF_LEGEND): cv.ensure_list(GRAPH_LEGEND_SCHEMA), + } +) + + +def _relocate_fields_to_subfolder(config, subfolder, subschema): + fields = [k.schema for k in subschema.schema.keys()] + fields.remove(CONF_ID) + if subfolder in config: + # Ensure no ambigious fields in base of config + for f in fields: + if f in config: + raise cv.Invalid( + "You cannot use the '" + + str(f) + + "' field when already using 'traces:'. " + "Please move it into 'traces:' entry." + ) + else: + # Copy over all fields to subfolder: + trace = {} + for f in fields: + if f in config: + trace[f] = config.pop(f) + config[subfolder] = cv.ensure_list(subschema)(trace) + return config + + +def _relocate_trace(config): + return _relocate_fields_to_subfolder(config, CONF_TRACES, GRAPH_TRACE_SCHEMA) + + +CONFIG_SCHEMA = cv.All( + GRAPH_SCHEMA, + _relocate_trace, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_duration(config[CONF_DURATION])) + cg.add(var.set_width(config[CONF_WIDTH])) + cg.add(var.set_height(config[CONF_HEIGHT])) + await cg.register_component(var, config) + + # Graph options + if CONF_X_GRID in config: + cg.add(var.set_grid_x(config[CONF_X_GRID])) + if CONF_Y_GRID in config: + cg.add(var.set_grid_y(config[CONF_Y_GRID])) + if CONF_BORDER in config: + cg.add(var.set_border(config[CONF_BORDER])) + # Axis related options + if CONF_MIN_VALUE in config: + cg.add(var.set_min_value(config[CONF_MIN_VALUE])) + if CONF_MAX_VALUE in config: + cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + if CONF_MIN_RANGE in config: + cg.add(var.set_min_range(config[CONF_MIN_RANGE])) + if CONF_MAX_RANGE in config: + cg.add(var.set_max_range(config[CONF_MAX_RANGE])) + # Trace options + for trace in config[CONF_TRACES]: + tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) + sens = await cg.get_variable(trace[CONF_SENSOR]) + cg.add(tr.set_sensor(sens)) + if CONF_NAME in trace: + cg.add(tr.set_name(trace[CONF_NAME])) + else: + cg.add(tr.set_name(trace[CONF_SENSOR].id)) + if CONF_LINE_THICKNESS in trace: + cg.add(tr.set_line_thickness(trace[CONF_LINE_THICKNESS])) + if CONF_LINE_TYPE in trace: + cg.add(tr.set_line_type(trace[CONF_LINE_TYPE])) + if CONF_COLOR in trace: + c = await cg.get_variable(trace[CONF_COLOR]) + cg.add(tr.set_line_color(c)) + cg.add(var.add_trace(tr)) + # Add legend + if CONF_LEGEND in config: + lgd = config[CONF_LEGEND][0] + legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) + if CONF_NAME_FONT in lgd: + font = await cg.get_variable(lgd[CONF_NAME_FONT]) + cg.add(legend.set_name_font(font)) + if CONF_VALUE_FONT in lgd: + font = await cg.get_variable(lgd[CONF_VALUE_FONT]) + cg.add(legend.set_value_font(font)) + if CONF_WIDTH in lgd: + cg.add(legend.set_width(lgd[CONF_WIDTH])) + if CONF_HEIGHT in lgd: + cg.add(legend.set_height(lgd[CONF_HEIGHT])) + if CONF_BORDER in lgd: + cg.add(legend.set_border(lgd[CONF_BORDER])) + if CONF_SHOW_LINES in lgd: + cg.add(legend.set_lines(lgd[CONF_SHOW_LINES])) + if CONF_SHOW_VALUES in lgd: + cg.add(legend.set_values(lgd[CONF_SHOW_VALUES])) + if CONF_SHOW_UNITS in lgd: + cg.add(legend.set_units(lgd[CONF_SHOW_UNITS])) + if CONF_DIRECTION in lgd: + cg.add(legend.set_direction(lgd[CONF_DIRECTION])) + cg.add(var.add_legend(legend)) + + cg.add_define("USE_GRAPH") diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp new file mode 100644 index 0000000000..6ede553fdb --- /dev/null +++ b/esphome/components/graph/graph.cpp @@ -0,0 +1,361 @@ +#include "graph.h" +#include "esphome/components/display/display_buffer.h" +#include "esphome/core/color.h" +#include "esphome/core/log.h" +#include "esphome/core/esphal.h" +#include +#include +#include // std::cout, std::fixed +#include +namespace esphome { +namespace graph { + +using namespace display; + +static const char *const TAG = "graph"; +static const char *const TAGL = "graphlegend"; + +void HistoryData::init(int length) { + this->length_ = length; + this->samples_.resize(length, NAN); + this->last_sample_ = millis(); +} + +void HistoryData::take_sample(float data) { + uint32_t tm = millis(); + uint32_t dt = tm - last_sample_; + last_sample_ = tm; + + // Step data based on time + this->period_ += dt; + while (this->period_ >= this->update_time_) { + this->samples_[this->count_] = data; + this->period_ -= this->update_time_; + this->count_ = (this->count_ + 1) % this->length_; + ESP_LOGV(TAG, "Updating trace with value: %f", data); + } + if (!isnan(data)) { + // Recalc recent max/min + this->recent_min_ = data; + this->recent_max_ = data; + for (int i = 0; i < this->length_; i++) { + if (!isnan(this->samples_[i])) { + if (this->recent_max_ < this->samples_[i]) + this->recent_max_ = this->samples_[i]; + if (this->recent_min_ > this->samples_[i]) + this->recent_min_ = this->samples_[i]; + } + } + } +} + +void GraphTrace::init(Graph *g) { + ESP_LOGI(TAG, "Init trace for sensor %s", this->get_name().c_str()); + this->data_.init(g->get_width()); + sensor_->add_on_state_callback([this](float state) { this->data_.take_sample(state); }); + this->data_.set_update_time_ms(g->get_duration() * 1000 / g->get_width()); +} + +void Graph::draw(DisplayBuffer *buff, uint16_t x_offset, uint16_t y_offset, Color color) { + /// Plot border + if (this->border_) { + buff->horizontal_line(x_offset, y_offset, this->width_, color); + buff->horizontal_line(x_offset, y_offset + this->height_ - 1, this->width_, color); + buff->vertical_line(x_offset, y_offset, this->height_, color); + buff->vertical_line(x_offset + this->width_ - 1, y_offset, this->height_, color); + } + /// Determine best y-axis scale and range + float ymin = NAN; + float ymax = NAN; + for (auto *trace : traces_) { + float mx = trace->get_tracedata()->get_recent_max(); + float mn = trace->get_tracedata()->get_recent_min(); + if (isnan(ymax) || (ymax < mx)) + ymax = mx; + if (isnan(ymin) || (ymin > mn)) + ymin = mn; + } + // Adjust if manually overridden + if (!isnan(this->min_value_)) + ymin = this->min_value_; + if (!isnan(this->max_value_)) + ymax = this->max_value_; + + float yrange = ymax - ymin; + if (yrange > this->max_range_) { + // Look back in trace data to best-fit into local range + float mx = NAN; + float mn = NAN; + for (int16_t i = 0; i < this->width_; i++) { + for (auto *trace : traces_) { + float v = trace->get_tracedata()->get_value(i); + if (!isnan(v)) { + if ((v - mn) > this->max_range_) + break; + if ((mx - v) > this->max_range_) + break; + if (isnan(mx) || (v > mx)) + mx = v; + if (isnan(mn) || (v < mn)) + mn = v; + } + } + } + yrange = this->max_range_; + if (!isnan(mn)) { + ymin = mn; + ymax = ymin + this->max_range_; + } + ESP_LOGV(TAG, "Graphing at max_range. Using local min %f, max %f", mn, mx); + } + + float y_per_div = this->min_range_; + if (!isnan(this->gridspacing_y_)) { + y_per_div = this->gridspacing_y_; + } + // Restrict drawing too many gridlines + if (yrange > 10 * y_per_div) { + while (yrange > 10 * y_per_div) { + y_per_div *= 2; + } + ESP_LOGW(TAG, "Graphing reducing y-scale to prevent too many gridlines"); + } + + // Adjust limits to nice y_per_div boundaries + int yn = int(ymin / y_per_div); + int ym = int(ymax / y_per_div) + int(1 * (fmodf(ymax, y_per_div) != 0)); + ymin = yn * y_per_div; + ymax = ym * y_per_div; + yrange = ymax - ymin; + + /// Draw grid + if (!isnan(this->gridspacing_y_)) { + for (int y = yn; y <= ym; y++) { + int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0 - (float) (y - yn) / (ym - yn))); + for (int x = 0; x < this->width_; x += 2) { + buff->draw_pixel_at(x_offset + x, y_offset + py, color); + } + } + } + if (!isnan(this->gridspacing_x_) && (this->gridspacing_x_ > 0)) { + int n = this->duration_ / this->gridspacing_x_; + // Restrict drawing too many gridlines + if (n > 20) { + while (n > 20) { + n /= 2; + } + ESP_LOGW(TAG, "Graphing reducing x-scale to prevent too many gridlines"); + } + for (int i = 0; i <= n; i++) { + for (int y = 0; y < this->height_; y += 2) { + buff->draw_pixel_at(x_offset + i * (this->width_ - 1) / n, y_offset + y, color); + } + } + } + + /// Draw traces + ESP_LOGV(TAG, "Updating graph. ymin %f, ymax %f", ymin, ymax); + for (auto *trace : traces_) { + Color c = trace->get_line_color(); + uint16_t thick = trace->get_line_thickness(); + for (int16_t i = 0; i < this->width_; i++) { + float v = (trace->get_tracedata()->get_value(i) - ymin) / yrange; + if (!isnan(v) && (thick > 0)) { + int16_t x = this->width_ - 1 - i; + uint8_t b = (i % (thick * LineType::PATTERN_LENGTH)) / thick; + if (((uint8_t) trace->get_line_type() & (1 << b)) == (1 << b)) { + int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0 - v)) - thick / 2; + for (int16_t t = 0; t < thick; t++) { + buff->draw_pixel_at(x_offset + x, y_offset + y + t, c); + } + } + } + } + } +} + +/// Determine the best coordinates of drawing text + lines +void GraphLegend::init(Graph *g) { + parent_ = g; + + // Determine maximum expected text and value width / height + int txtw = 0, txtos = 0, txtbl = 0, txth = 0; + int valw = 0, valos = 0, valbl = 0, valh = 0; + int lt = 0; + for (auto *trace : g->traces_) { + std::string txtstr = trace->get_name(); + int fw, fos, fbl, fh; + this->font_label->measure(txtstr.c_str(), &fw, &fos, &fbl, &fh); + if (fw > txtw) + txtw = fw; + if (fh > txth) + txth = fh; + if (trace->get_line_thickness() > lt) + lt = trace->get_line_thickness(); + ESP_LOGI(TAGL, " %s %d %d", txtstr.c_str(), fw, fh); + + if (this->values_ != VALUE_POSITION_TYPE_NONE) { + std::stringstream ss; + ss << std::fixed << std::setprecision(trace->sensor_->get_accuracy_decimals()) << trace->sensor_->get_state(); + std::string valstr = ss.str(); + if (this->units_) { + valstr += trace->sensor_->get_unit_of_measurement(); + } + this->font_value->measure(valstr.c_str(), &fw, &fos, &fbl, &fh); + if (fw > valw) + valw = fw; + if (fh > valh) + valh = fh; + ESP_LOGI(TAGL, " %s %d %d", valstr.c_str(), fw, fh); + } + } + // Add extra margin + txtw *= 1.2; + valw *= 1.2; + + uint8_t n = g->traces_.size(); + uint16_t w = this->width_; + uint16_t h = this->height_; + DirectionType dir = this->direction_; + ValuePositionType valpos = this->values_; + if (!this->font_value) { + valpos = VALUE_POSITION_TYPE_NONE; + } + // Line sample always goes below text for compactness + this->yl = txth + (txth / 4) + lt / 2; + + if (dir == DIRECTION_TYPE_AUTO) { + dir = DIRECTION_TYPE_HORIZONTAL; // as default + if (h > 0) { + dir = DIRECTION_TYPE_VERTICAL; + } + } + + if (valpos == VALUE_POSITION_TYPE_AUTO) { + // TODO: do something smarter?? - fit to w and h? + valpos = VALUE_POSITION_TYPE_BELOW; + } + + if (valpos == VALUE_POSITION_TYPE_BELOW) { + this->yv = txth + (txth / 4); + if (this->lines_) + this->yv += txth / 4 + lt; + } else if (valpos == VALUE_POSITION_TYPE_BESIDE) { + this->xv = (txtw + valw) / 2; + } + + // If width or height is specified we divide evenly within, else we do tight-fit + if (w == 0) { + this->x0 = txtw / 2; + this->xs = txtw; + if (valpos == VALUE_POSITION_TYPE_BELOW) { + this->xs = std::max(txtw, valw); + ; + this->x0 = this->xs / 2; + } else if (valpos == VALUE_POSITION_TYPE_BESIDE) { + this->xs = txtw + valw; + } + if (dir == DIRECTION_TYPE_VERTICAL) { + this->width_ = this->xs; + } else { + this->width_ = this->xs * n; + } + } else { + this->xs = w / n; + this->x0 = this->xs / 2; + } + + if (h == 0) { + this->ys = txth; + if (valpos == VALUE_POSITION_TYPE_BELOW) { + this->ys = txth + txth / 2 + valh; + if (this->lines_) { + this->ys += lt; + } + } else if (valpos == VALUE_POSITION_TYPE_BESIDE) { + if (this->lines_) { + this->ys = std::max(txth + txth / 4 + lt + txth / 4, valh + valh / 4); + } else { + this->ys = std::max(txth + txth / 4, valh + valh / 4); + } + this->height_ = this->ys * n; + } + if (dir == DIRECTION_TYPE_HORIZONTAL) { + this->height_ = this->ys; + } else { + this->height_ = this->ys * n; + } + } else { + this->ys = h / n; + } + + if (dir == DIRECTION_TYPE_HORIZONTAL) { + this->ys = 0; + } else { + this->xs = 0; + } +} + +void Graph::draw_legend(display::DisplayBuffer *buff, uint16_t x_offset, uint16_t y_offset, Color color) { + if (!legend_) + return; + + /// Plot border + if (this->border_) { + int w = legend_->width_; + int h = legend_->height_; + buff->horizontal_line(x_offset, y_offset, w, color); + buff->horizontal_line(x_offset, y_offset + h - 1, w, color); + buff->vertical_line(x_offset, y_offset, h, color); + buff->vertical_line(x_offset + w - 1, y_offset, h, color); + } + + int x = x_offset + legend_->x0; + int y = y_offset; + for (auto *trace : traces_) { + std::string txtstr = trace->get_name(); + ESP_LOGV(TAG, " %s", txtstr.c_str()); + + buff->printf(x, y, legend_->font_label, trace->get_line_color(), TextAlign::TOP_CENTER, "%s", txtstr.c_str()); + + if (legend_->lines_) { + uint16_t thick = trace->get_line_thickness(); + for (int16_t i = 0; i < legend_->x0 * 4 / 3; i++) { + uint8_t b = (i % (thick * LineType::PATTERN_LENGTH)) / thick; + if (((uint8_t) trace->get_line_type() & (1 << b)) == (1 << b)) { + buff->vertical_line(x - legend_->x0 * 2 / 3 + i, y + legend_->yl - thick / 2, thick, trace->get_line_color()); + } + } + } + + if (legend_->values_ != VALUE_POSITION_TYPE_NONE) { + int xv = x + legend_->xv; + int yv = y + legend_->yv; + std::stringstream ss; + ss << std::fixed << std::setprecision(trace->sensor_->get_accuracy_decimals()) << trace->sensor_->get_state(); + std::string valstr = ss.str(); + if (legend_->units_) { + valstr += trace->sensor_->get_unit_of_measurement(); + } + buff->printf(xv, yv, legend_->font_value, trace->get_line_color(), TextAlign::TOP_CENTER, "%s", valstr.c_str()); + ESP_LOGV(TAG, " value: %s", valstr.c_str()); + } + x += legend_->xs; + y += legend_->ys; + } +} + +void Graph::setup() { + for (auto *trace : traces_) { + trace->init(this); + } +} + +void Graph::dump_config() { + for (auto *trace : traces_) { + ESP_LOGCONFIG(TAG, "Graph for sensor %s", trace->get_name().c_str()); + } +} + +} // namespace graph +} // namespace esphome diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h new file mode 100644 index 0000000000..8f6e74f67a --- /dev/null +++ b/esphome/components/graph/graph.h @@ -0,0 +1,178 @@ +#pragma once +#include +#include "esphome/core/color.h" +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome { + +// forward declare DisplayBuffer +namespace display { +class DisplayBuffer; +class Font; +} // namespace display + +namespace graph { + +class Graph; + +const Color COLOR_ON(255, 255, 255, 255); + +/// Bit pattern defines the line-type +enum LineType { + LINE_TYPE_SOLID = 0b1111, + LINE_TYPE_DOTTED = 0b0101, + LINE_TYPE_DASHED = 0b1110, + // Following defines number of bits used to define line pattern + PATTERN_LENGTH = 4 +}; + +enum DirectionType { + DIRECTION_TYPE_AUTO, + DIRECTION_TYPE_HORIZONTAL, + DIRECTION_TYPE_VERTICAL, +}; + +enum ValuePositionType { + VALUE_POSITION_TYPE_NONE, + VALUE_POSITION_TYPE_AUTO, + VALUE_POSITION_TYPE_BESIDE, + VALUE_POSITION_TYPE_BELOW +}; + +class GraphLegend { + public: + void init(Graph *g); + void set_name_font(display::Font *font) { this->font_label = font; } + void set_value_font(display::Font *font) { this->font_value = font; } + void set_width(uint32_t width) { this->width_ = width; } + void set_height(uint32_t height) { this->height_ = height; } + void set_border(bool val) { this->border_ = val; } + void set_lines(bool val) { this->lines_ = val; } + void set_values(ValuePositionType val) { this->values_ = val; } + void set_units(bool val) { this->units_ = val; } + void set_direction(DirectionType val) { this->direction_ = val; } + + protected: + uint32_t width_{0}; + uint32_t height_{0}; + bool border_{true}; + bool lines_{true}; + ValuePositionType values_{VALUE_POSITION_TYPE_AUTO}; + bool units_{true}; + DirectionType direction_{DIRECTION_TYPE_AUTO}; + display::Font *font_label{nullptr}; + display::Font *font_value{nullptr}; + // Calculated values + Graph *parent_{nullptr}; + // (x0) (xs,ys) (xs,ys) + // ------> LABEL1 -------> LABEL2 -------> ... + // | \(xv,yv) \ . + // | \ \-> VALUE1+units + // (0,yl)| \-> VALUE1+units + // v (top_center) + // LINE_SAMPLE + int x0{0}; // X-offset to centre of label text + int xs{0}; // X spacing between labels + int ys{0}; // Y spacing between labels + int yl{0}; // Y spacing from label to line sample + int xv{0}; // X distance between label to value text + int yv{0}; // Y distance between label to value text + friend Graph; +}; + +class HistoryData { + public: + void init(int length); + ~HistoryData(); + void set_update_time_ms(uint32_t update_time_ms) { update_time_ = update_time_ms; } + void take_sample(float data); + int get_length() const { return length_; } + float get_value(int idx) const { return samples_[(count_ + length_ - 1 - idx) % length_]; } + float get_recent_max() const { return recent_max_; } + float get_recent_min() const { return recent_min_; } + + protected: + uint32_t last_sample_; + uint32_t period_{0}; /// in ms + uint32_t update_time_{0}; /// in ms + int length_; + int count_{0}; + float recent_min_{NAN}; + float recent_max_{NAN}; + std::vector samples_; +}; + +class GraphTrace { + public: + void init(Graph *g); + void set_name(std::string name) { name_ = name; } + void set_sensor(sensor::Sensor *sensor) { sensor_ = sensor; } + uint8_t get_line_thickness() { return this->line_thickness_; } + void set_line_thickness(uint8_t val) { this->line_thickness_ = val; } + enum LineType get_line_type() { return this->line_type_; } + void set_line_type(enum LineType val) { this->line_type_ = val; } + Color get_line_color() { return this->line_color_; } + void set_line_color(Color val) { this->line_color_ = val; } + const std::string get_name(void) { return name_; } + const HistoryData *get_tracedata() { return &data_; } + + protected: + sensor::Sensor *sensor_{nullptr}; + std::string name_{""}; + uint8_t line_thickness_{3}; + enum LineType line_type_ { LINE_TYPE_SOLID }; + Color line_color_{COLOR_ON}; + HistoryData data_; + + friend Graph; + friend GraphLegend; +}; + +class Graph : public Component { + public: + void draw(display::DisplayBuffer *buff, uint16_t x_offset, uint16_t y_offset, Color color); + void draw_legend(display::DisplayBuffer *buff, uint16_t x_offset, uint16_t y_offset, Color color); + + void setup() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void dump_config() override; + + void set_duration(uint32_t duration) { duration_ = duration; } + void set_width(uint32_t width) { width_ = width; } + void set_height(uint32_t height) { height_ = height; } + void set_min_value(float val) { this->min_value_ = val; } + void set_max_value(float val) { this->max_value_ = val; } + void set_min_range(float val) { this->min_range_ = val; } + void set_max_range(float val) { this->max_range_ = val; } + void set_grid_x(float val) { this->gridspacing_x_ = val; } + void set_grid_y(float val) { this->gridspacing_y_ = val; } + void set_border(bool val) { this->border_ = val; } + void add_trace(GraphTrace *trace) { traces_.push_back(trace); } + void add_legend(GraphLegend *legend) { + this->legend_ = legend; + legend->init(this); + } + uint32_t get_duration() { return duration_; } + uint32_t get_width() { return width_; } + uint32_t get_height() { return height_; } + + protected: + uint32_t duration_; /// in seconds + uint32_t width_; /// in pixels + uint32_t height_; /// in pixels + float min_value_{NAN}; + float max_value_{NAN}; + float min_range_{1.0}; + float max_range_{NAN}; + float gridspacing_x_{NAN}; + float gridspacing_y_{NAN}; + bool border_{true}; + std::vector traces_; + GraphLegend *legend_{nullptr}; + + friend GraphLegend; +}; + +} // namespace graph +} // namespace esphome diff --git a/esphome/const.py b/esphome/const.py index a74068ab77..598b6351b4 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -90,6 +90,7 @@ CONF_BIT_DEPTH = "bit_depth" CONF_BLUE = "blue" CONF_BOARD = "board" CONF_BOARD_FLASH_MODE = "board_flash_mode" +CONF_BORDER = "border" CONF_BRANCH = "branch" CONF_BRIGHTNESS = "brightness" CONF_BROKER = "broker" @@ -327,6 +328,7 @@ CONF_LAMBDA = "lambda" CONF_LAST_CONFIDENCE = "last_confidence" CONF_LAST_FINGER_ID = "last_finger_id" CONF_LATITUDE = "latitude" +CONF_LEGEND = "legend" CONF_LENGTH = "length" CONF_LEVEL = "level" CONF_LG = "lg" @@ -334,6 +336,8 @@ CONF_LIBRARIES = "libraries" CONF_LIGHT = "light" CONF_LIGHTNING_ENERGY = "lightning_energy" CONF_LIGHTNING_THRESHOLD = "lightning_threshold" +CONF_LINE_THICKNESS = "line_thickness" +CONF_LINE_TYPE = "line_type" CONF_LOADED_INTEGRATIONS = "loaded_integrations" CONF_LOCAL = "local" CONF_LOG_TOPIC = "log_topic" @@ -355,6 +359,7 @@ CONF_MAX_HEATING_RUN_TIME = "max_heating_run_time" CONF_MAX_LENGTH = "max_length" CONF_MAX_LEVEL = "max_level" CONF_MAX_POWER = "max_power" +CONF_MAX_RANGE = "max_range" CONF_MAX_REFRESH_RATE = "max_refresh_rate" CONF_MAX_SPEED = "max_speed" CONF_MAX_TEMPERATURE = "max_temperature" @@ -376,6 +381,7 @@ CONF_MIN_IDLE_TIME = "min_idle_time" CONF_MIN_LENGTH = "min_length" CONF_MIN_LEVEL = "min_level" CONF_MIN_POWER = "min_power" +CONF_MIN_RANGE = "min_range" CONF_MIN_TEMPERATURE = "min_temperature" CONF_MIN_VALUE = "min_value" CONF_MINUTE = "minute" @@ -393,6 +399,7 @@ CONF_MQTT_ID = "mqtt_id" CONF_MULTIPLEXER = "multiplexer" CONF_MULTIPLY = "multiply" CONF_NAME = "name" +CONF_NAME_FONT = "name_font" CONF_NBITS = "nbits" CONF_NEC = "nec" CONF_NETWORKS = "networks" @@ -584,6 +591,9 @@ CONF_SERVICES = "services" CONF_SET_POINT_MINIMUM_DIFFERENTIAL = "set_point_minimum_differential" CONF_SETUP_MODE = "setup_mode" CONF_SETUP_PRIORITY = "setup_priority" +CONF_SHOW_LINES = "show_lines" +CONF_SHOW_UNITS = "show_units" +CONF_SHOW_VALUES = "show_values" CONF_SHUNT_RESISTANCE = "shunt_resistance" CONF_SHUNT_VOLTAGE = "shunt_voltage" CONF_SHUTDOWN_MESSAGE = "shutdown_message" @@ -659,6 +669,7 @@ CONF_TOLERANCE = "tolerance" CONF_TOPIC = "topic" CONF_TOPIC_PREFIX = "topic_prefix" CONF_TOTAL = "total" +CONF_TRACES = "traces" CONF_TRANSITION_LENGTH = "transition_length" CONF_TRIGGER_ID = "trigger_id" CONF_TRIGGER_PIN = "trigger_pin" @@ -681,6 +692,7 @@ CONF_USE_ADDRESS = "use_address" CONF_USERNAME = "username" CONF_UUID = "uuid" CONF_VALUE = "value" +CONF_VALUE_FONT = "value_font" CONF_VARIABLES = "variables" CONF_VARIANT = "variant" CONF_VERSION = "version" @@ -704,6 +716,8 @@ CONF_WILL_MESSAGE = "will_message" CONF_WIND_DIRECTION_DEGREES = "wind_direction_degrees" CONF_WIND_SPEED = "wind_speed" CONF_WINDOW_SIZE = "window_size" +CONF_X_GRID = "x_grid" +CONF_Y_GRID = "y_grid" CONF_ZERO = "zero" ENV_NOGITIGNORE = "ESPHOME_NOGITIGNORE" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e3976d378e..89010ce246 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -19,6 +19,7 @@ #define USE_DEEP_SLEEP #define USE_ESP8266_PREFERENCES_FLASH #define USE_FAN +#define USE_GRAPH #define USE_HOMEASSISTANT_TIME #define USE_HTTP_REQUEST_ESP8266_HTTPS #define USE_I2C_MULTIPLEXER