Add SCD30 climate dashboard and history graphs
This commit is contained in:
+174
-3
@@ -1,5 +1,5 @@
|
|||||||
substitutions:
|
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"
|
name: "energymonitor"
|
||||||
friendly_name: "Energy Monitor"
|
friendly_name: "Energy Monitor"
|
||||||
|
|
||||||
@@ -87,6 +87,16 @@ spi:
|
|||||||
clk_pin: GPIO18
|
clk_pin: GPIO18
|
||||||
mosi_pin: GPIO23
|
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:
|
output:
|
||||||
- platform: gpio
|
- platform: gpio
|
||||||
pin: GPIO32
|
pin: GPIO32
|
||||||
@@ -170,6 +180,30 @@ globals:
|
|||||||
type: int
|
type: int
|
||||||
restore_value: true
|
restore_value: true
|
||||||
initial_value: '0'
|
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.
|
# 361 ten-second samples span one hour; unfilled slots are never drawn.
|
||||||
- id: internet_download_history
|
- id: internet_download_history
|
||||||
type: std::array<float, 361>
|
type: std::array<float, 361>
|
||||||
@@ -189,6 +223,26 @@ globals:
|
|||||||
initial_value: '0'
|
initial_value: '0'
|
||||||
|
|
||||||
sensor:
|
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
|
# WiFi signal strength
|
||||||
- platform: wifi_signal
|
- platform: wifi_signal
|
||||||
name: "WiFi Signalstärke"
|
name: "WiFi Signalstärke"
|
||||||
@@ -336,6 +390,17 @@ interval:
|
|||||||
id(internet_history_next) = (slot + 1) % 361;
|
id(internet_history_next) = (slot + 1) % 361;
|
||||||
if (id(internet_history_count) < 361) id(internet_history_count)++;
|
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:
|
script:
|
||||||
- id: refresh_display
|
- id: refresh_display
|
||||||
then:
|
then:
|
||||||
@@ -344,7 +409,7 @@ script:
|
|||||||
- component.update: tft
|
- component.update: tft
|
||||||
- id: show_previous_page
|
- id: show_previous_page
|
||||||
then:
|
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
|
- script.execute: refresh_display
|
||||||
- id: toggle_page_mode
|
- id: toggle_page_mode
|
||||||
then:
|
then:
|
||||||
@@ -357,11 +422,13 @@ script:
|
|||||||
id(clock_digital_view) = !id(clock_digital_view);
|
id(clock_digital_view) = !id(clock_digital_view);
|
||||||
} else if (id(page_index) == 3) {
|
} else if (id(page_index) == 3) {
|
||||||
id(internet_view) = (id(internet_view) + 1) % 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
|
- script.execute: refresh_display
|
||||||
- id: show_next_page
|
- id: show_next_page
|
||||||
then:
|
then:
|
||||||
- lambda: id(page_index) = (id(page_index) + 1) % 4;
|
- lambda: id(page_index) = (id(page_index) + 1) % 5;
|
||||||
- script.execute: refresh_display
|
- script.execute: refresh_display
|
||||||
|
|
||||||
button:
|
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.
|
// Labels correspond to the left (A) and right (C) hardware buttons.
|
||||||
it.printf(8, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_LEFT,
|
it.printf(8, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_LEFT,
|
||||||
"< Zurück");
|
"< Zurück");
|
||||||
@@ -906,4 +1065,16 @@ display:
|
|||||||
"%s · %d / 3", id(internet_view) == 0 ? "Live"
|
"%s · %d / 3", id(internet_view) == 0 ? "Live"
|
||||||
: id(internet_view) == 1 ? "Verlauf" : "Volumen",
|
: id(internet_view) == 1 ? "Verlauf" : "Volumen",
|
||||||
id(internet_view) + 1);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user