Add SCD30 climate dashboard and history graphs

This commit is contained in:
2026-11-06 14:14:49 +01:00
parent 0f1a6d1878
commit cdc89e68d3
+174 -3
View File
@@ -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<float, 361>
restore_value: false
initial_value: 'std::array<float, 361>{}'
- id: climate_temperature_history
type: std::array<float, 361>
restore_value: false
initial_value: 'std::array<float, 361>{}'
- id: climate_humidity_history
type: std::array<float, 361>
restore_value: false
initial_value: 'std::array<float, 361>{}'
- 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<float, 361>
@@ -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<float, 361> *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);
}