diff --git a/esphome/energymonitor.yaml b/esphome/energymonitor.yaml index 6a3a676..22a1197 100644 --- a/esphome/energymonitor.yaml +++ b/esphome/energymonitor.yaml @@ -1,5 +1,5 @@ substitutions: - device_description: "M5Stack Core dashboard for Home Assistant energy and internet usage, with analog/digital clock and WireGuard" + device_description: "M5Stack Core dashboard for Home Assistant energy and internet usage, SCD30 room climate, analog/digital clock and WireGuard" name: "energymonitor" friendly_name: "Energy Monitor" @@ -87,6 +87,16 @@ spi: clk_pin: GPIO18 mosi_pin: GPIO23 +# Grove Port A: white = SDA, yellow = SCL, red = 5 V, black = GND. +i2c: + id: grove_i2c + sda: GPIO21 + scl: GPIO22 + frequency: 50kHz + # Allow clock stretching by the SCD30 (ESP32 hardware limit). + timeout: 13ms + scan: true + output: - platform: gpio pin: GPIO32 @@ -170,6 +180,30 @@ globals: type: int restore_value: true initial_value: '0' + - id: climate_graph_view + type: bool + restore_value: true + initial_value: 'false' + - id: climate_co2_history + type: std::array + restore_value: false + initial_value: 'std::array{}' + - id: climate_temperature_history + type: std::array + restore_value: false + initial_value: 'std::array{}' + - id: climate_humidity_history + type: std::array + restore_value: false + initial_value: 'std::array{}' + - id: climate_history_next + type: int + restore_value: false + initial_value: '0' + - id: climate_history_count + type: int + restore_value: false + initial_value: '0' # 361 ten-second samples span one hour; unfilled slots are never drawn. - id: internet_download_history type: std::array @@ -189,6 +223,26 @@ globals: initial_value: '0' sensor: + - platform: scd30 + id: room_climate + i2c_id: grove_i2c + address: 0x61 + update_interval: 5s + co2: + id: scd30_co2 + name: "CO2" + filters: + - timeout: 30s + temperature: + id: scd30_temperature + name: "Temperature" + filters: + - timeout: 30s + humidity: + id: scd30_humidity + name: "Humidity" + filters: + - timeout: 30s # WiFi signal strength - platform: wifi_signal name: "WiFi Signalstärke" @@ -336,6 +390,17 @@ interval: id(internet_history_next) = (slot + 1) % 361; if (id(internet_history_count) < 361) id(internet_history_count)++; + // Local measurements keep recording even without Home Assistant or Wi-Fi. + const int climate_slot = id(climate_history_next); + auto sample_climate = [](sensor::Sensor *source) -> float { + return source->has_state() && std::isfinite(source->state) ? source->state : NAN; + }; + id(climate_co2_history)[climate_slot] = sample_climate(id(scd30_co2)); + id(climate_temperature_history)[climate_slot] = sample_climate(id(scd30_temperature)); + id(climate_humidity_history)[climate_slot] = sample_climate(id(scd30_humidity)); + id(climate_history_next) = (climate_slot + 1) % 361; + if (id(climate_history_count) < 361) id(climate_history_count)++; + script: - id: refresh_display then: @@ -344,7 +409,7 @@ script: - component.update: tft - id: show_previous_page then: - - lambda: id(page_index) = (id(page_index) + 4 - 1) % 4; + - lambda: id(page_index) = (id(page_index) + 5 - 1) % 5; - script.execute: refresh_display - id: toggle_page_mode then: @@ -357,11 +422,13 @@ script: id(clock_digital_view) = !id(clock_digital_view); } else if (id(page_index) == 3) { id(internet_view) = (id(internet_view) + 1) % 3; + } else if (id(page_index) == 4) { + id(climate_graph_view) = !id(climate_graph_view); } - script.execute: refresh_display - id: show_next_page then: - - lambda: id(page_index) = (id(page_index) + 1) % 4; + - lambda: id(page_index) = (id(page_index) + 1) % 5; - script.execute: refresh_display button: @@ -885,6 +952,98 @@ display: } } + if (id(page_index) == 4) { + const Color co2_color(143, 190, 223); + const Color temperature_color(255, 170, 40); + const Color humidity_color(93, 207, 140); + const bool co2_valid = id(scd30_co2).has_state() && std::isfinite(id(scd30_co2).state); + const bool temperature_valid = id(scd30_temperature).has_state() + && std::isfinite(id(scd30_temperature).state); + const bool humidity_valid = id(scd30_humidity).has_state() + && std::isfinite(id(scd30_humidity).state); + if (!id(climate_graph_view)) { + draw_page_title("Raumklima"); + it.print(160, 38, id(font_small), co2_color, TextAlign::TOP_CENTER, "CO2"); + it.printf(160, 58, id(font_value), co2_color, TextAlign::TOP_CENTER, + co2_valid ? "%.0f ppm" : "-- ppm", id(scd30_co2).state); + it.print(80, 140, id(font_small), temperature_color, TextAlign::TOP_CENTER, "Temperatur"); + it.printf(80, 164, id(font_title), temperature_color, TextAlign::TOP_CENTER, + temperature_valid ? "%.1f °C" : "-- °C", id(scd30_temperature).state); + it.print(240, 140, id(font_small), humidity_color, TextAlign::TOP_CENTER, "Feuchte"); + it.printf(240, 164, id(font_title), humidity_color, TextAlign::TOP_CENTER, + humidity_valid ? "%.1f %%" : "-- %%", id(scd30_humidity).state); + if (!co2_valid && !temperature_valid && !humidity_valid) { + it.print(160, 204, id(font_device), Color::WHITE, TextAlign::TOP_CENTER, + "Warte auf SCD30-Messwerte"); + } + } else { + draw_page_title("Raumklima · 1 h"); + const int left = 28, right = 308; + const int count = id(climate_history_count); + const char *labels[] = {"CO2", "Temperatur", "Feuchte"}; + const char *units[] = {"ppm", "°C", "%"}; + const Color colors[] = {co2_color, temperature_color, humidity_color}; + const std::array *histories[] = { + &id(climate_co2_history), &id(climate_temperature_history), &id(climate_humidity_history)}; + const float scale_steps[] = {100.0f, 1.0f, 5.0f}; + const float minimum_ranges[] = {200.0f, 2.0f, 10.0f}; + for (int graph = 0; graph < 3; graph++) { + const int top = 52 + graph * 54; + const int bottom = top + 31; + float minimum = INFINITY, maximum = -INFINITY; + for (int sample = 0; sample < count; sample++) { + const int slot = (id(climate_history_next) - count + sample + 361) % 361; + const float value = (*histories[graph])[slot]; + if (std::isfinite(value)) { + minimum = fminf(minimum, value); + maximum = fmaxf(maximum, value); + } + } + const bool has_samples = std::isfinite(minimum); + if (has_samples) { + const float middle = (minimum + maximum) / 2.0f; + const float range = fmaxf(maximum - minimum, minimum_ranges[graph]); + minimum = floorf((middle - range / 2.0f) / scale_steps[graph]) * scale_steps[graph]; + maximum = ceilf((middle + range / 2.0f) / scale_steps[graph]) * scale_steps[graph]; + } + it.print(12, top - 16, id(font_device), colors[graph], labels[graph]); + if (has_samples) { + it.printf(right, top - 16, id(font_device), colors[graph], TextAlign::TOP_RIGHT, + "%.0f - %.0f %s", minimum, maximum, units[graph]); + } else { + it.print(right, top - 16, id(font_device), colors[graph], TextAlign::TOP_RIGHT, + "Warte auf Messwerte"); + } + for (int minute = 0; minute <= 60; minute += 15) { + const int x = left + minute * (right - left) / 60; + it.line(x, top, x, bottom, Color(42, 42, 42)); + } + it.line(left, top, right, top, Color(42, 42, 42)); + it.line(left, bottom, right, bottom, Color(42, 42, 42)); + int previous_x = 0, previous_y = 0; + bool previous_valid = false; + for (int sample = 0; sample < count; sample++) { + const int slot = (id(climate_history_next) - count + sample + 361) % 361; + const float value = (*histories[graph])[slot]; + const bool valid = std::isfinite(value); + const int x = right - (count - 1 - sample) * (right - left) / 360; + if (valid) { + const int y = bottom - lroundf((value - minimum) / (maximum - minimum) * (bottom - top)); + it.line(previous_valid ? previous_x : x, previous_valid ? previous_y : y, + x, y, colors[graph]); + previous_x = x; + previous_y = y; + } + previous_valid = valid; + } + } + it.print(left, 202, id(font_device), Color::WHITE, "-60 min"); + it.print((left + right) / 2, 202, id(font_device), Color::WHITE, + TextAlign::TOP_CENTER, "-30 min"); + it.print(right, 202, id(font_device), Color::WHITE, TextAlign::TOP_RIGHT, "Jetzt"); + } + } + // Labels correspond to the left (A) and right (C) hardware buttons. it.printf(8, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_LEFT, "< Zurück"); @@ -906,4 +1065,16 @@ display: "%s · %d / 3", id(internet_view) == 0 ? "Live" : id(internet_view) == 1 ? "Verlauf" : "Volumen", id(internet_view) + 1); + } else if (id(page_index) == 4) { + it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER, + "%s · %d / 2", id(climate_graph_view) ? "Verlauf" : "Live", + id(climate_graph_view) ? 2 : 1); + } + + // Draw last so high CO2 remains visible on every page and subpage. + if (id(scd30_co2).has_state() && std::isfinite(id(scd30_co2).state) + && id(scd30_co2).state > 1200.0f) { + const Color warning_color = id(scd30_co2).state > 1600.0f + ? Color(255, 0, 0) : Color(255, 170, 40); + it.filled_circle(160, 120, 5, warning_color); }