Files
homeassistant-config/esphome/energymonitor.yaml
T

1081 lines
46 KiB
YAML

substitutions:
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"
esphome:
comment: ${device_description}
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
project:
name: monitor.energymonitor
version: "1.0"
on_boot:
then:
- light.turn_on: display_backlight
esp32:
board: m5stack-core-esp32
framework:
type: esp-idf
advanced:
minimum_chip_revision: "3.1"
sram1_as_iram: true
network:
enable_ipv6: true
wifi:
networks:
- ssid: "Voltage-legacy"
password: !secret voltage_legacy_psk
priority: 10
- ssid: "Moppelkotze"
password: !secret moppelkotze_psk
priority: 5
- ssid: "TKRZ"
password: !secret tkrz_psk
priority: 4
use_address: 192.168.126.250
on_connect:
- lambda: id(wifi_connected) = true;
on_disconnect:
- lambda: id(wifi_connected) = false;
power_save_mode: high
min_auth_mode: WPA2
ap:
ssid: "Energymonitor Fallback Hotspot"
password: !secret fallback_psk
captive_portal:
time:
- platform: sntp
id: sntp_time
timezone: Europe/Berlin
servers:
- de.pool.ntp.org
# Values are maintained in secrets.yaml.
wireguard:
id: energymonitor_wireguard
address: !secret energymonitor_wireguard_address
netmask: !secret energymonitor_wireguard_netmask
private_key: !secret energymonitor_wireguard_private_key
peer_endpoint: !secret energymonitor_wireguard_peer_endpoint
peer_port: !secret energymonitor_wireguard_peer_port
peer_public_key: !secret energymonitor_wireguard_peer_public_key
peer_allowed_ips:
- !secret energymonitor_wireguard_peer_allowed_ip
peer_persistent_keepalive: 25s
# Avoid rebooting while the placeholder endpoint is intentionally unreachable.
reboot_timeout: 0s
logger:
api:
id: api_server
encryption:
key: !secret apikey
ota:
platform: esphome
encryption:
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
id: display_backlight_output
light:
- platform: binary
id: display_backlight
name: "Display Backlight"
output: display_backlight_output
restore_mode: ALWAYS_ON
font:
- file: "fonts/Roboto-Regular.ttf"
id: font_small
size: 18
glyphsets:
- GF_Latin_Kernel
glyphs: ["äöüÄÖÜß"]
- file: "fonts/Roboto-Bold.ttf"
id: font_title
size: 28
glyphsets:
- GF_Latin_Kernel
glyphs: ["äöüÄÖÜß"]
- file: "fonts/Roboto-Bold.ttf"
id: font_value
size: 52
glyphsets:
- GF_Latin_Kernel
glyphs: ["äöüÄÖÜß"]
- file: "fonts/Roboto-Bold.ttf"
id: font_internet_value
size: 22
glyphsets:
- GF_Latin_Kernel
- file: "fonts/Roboto-Regular.ttf"
id: font_device
size: 13
glyphsets:
- GF_Latin_Kernel
glyphs: ["äöüÄÖÜß"]
- file: "fonts/materialdesignicons-webfont.ttf"
id: font_icon
size: 24
glyphs:
- "\U000F0306" # mdi:key
- "\U000F07D0" # mdi:home-assistant
- "\U000F091F" # mdi:wifi-strength-1
- "\U000F0922" # mdi:wifi-strength-2
- "\U000F0925" # mdi:wifi-strength-3
- "\U000F0928" # mdi:wifi-strength-4
- "\U000F092D" # mdi:wifi-strength-off
globals:
- id: display_time
type: esphome::ESPTime
restore_value: false
initial_value: 'esphome::ESPTime{}'
- id: page_index
type: int
restore_value: true
initial_value: '0'
- id: wifi_connected
type: bool
restore_value: false
initial_value: 'false'
- id: page_one_gauge_view
type: bool
restore_value: true
initial_value: 'false'
- id: clock_digital_view
type: bool
restore_value: true
initial_value: 'false'
- id: device_usage_view
type: int
restore_value: true
initial_value: '0'
- id: internet_view
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>
restore_value: false
initial_value: 'std::array<float, 361>{}'
- id: internet_upload_history
type: std::array<float, 361>
restore_value: false
initial_value: 'std::array<float, 361>{}'
- id: internet_history_next
type: int
restore_value: false
initial_value: '0'
- id: internet_history_count
type: int
restore_value: false
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"
id: wifisignal
update_interval: 10s
- platform: homeassistant
id: grid_power
entity_id: sensor.netzleistung
internal: true
- platform: homeassistant
id: balcony_solar_power
entity_id: sensor.balkonkraftwerk_power
internal: true
- platform: homeassistant
id: solar_energy_daily
entity_id: sensor.energy_solar_daily
internal: true
- platform: homeassistant
id: grid_import_energy_daily
entity_id: sensor.energy_import_daily
internal: true
- platform: homeassistant
id: grid_export_energy_daily
entity_id: sensor.energy_export_daily
internal: true
- platform: homeassistant
id: hallway_fragrance_power
entity_id: sensor.flur_raumduft_power
internal: true
- platform: homeassistant
id: cellar_power
entity_id: sensor.keller_power
internal: true
- platform: homeassistant
id: sideboard_power
entity_id: sensor.kuche_anrichte_power
internal: true
- platform: homeassistant
id: kitchen_music_power
entity_id: sensor.kuche_musik_power
internal: true
- platform: homeassistant
id: bedroom_bed_power
entity_id: sensor.schlafzimmer_bett_power
internal: true
- platform: homeassistant
id: bedroom_cinema_power
entity_id: sensor.schlafzimmer_heimkino_power
internal: true
- platform: homeassistant
id: desk_decoration_power
entity_id: sensor.schreibtisch_deko_power
internal: true
- platform: homeassistant
id: desk_power
entity_id: sensor.schreibtisch_power
internal: true
- platform: homeassistant
id: server_room_power
entity_id: sensor.serverraum_power
internal: true
- platform: homeassistant
id: living_room_cinema_power
entity_id: sensor.wohnzimmer_heimkino_power
internal: true
- platform: homeassistant
id: games_cabinet_power
entity_id: sensor.wohnzimmer_spieleschrank_power
internal: true
- platform: homeassistant
id: refrigerator_power
entity_id: sensor.tz3000_ww6drja5_ts011f_leistung
internal: true
- platform: homeassistant
id: coffee_machine_power
entity_id: sensor.kaffeemaschine_leistung_2
internal: true
- platform: homeassistant
id: dryer_power
entity_id: sensor.ikea_of_sweden_inspelning_smart_plug_leistung
internal: true
- platform: homeassistant
id: washing_machine_power
entity_id: sensor.waschmaschine_leistung
internal: true
- platform: homeassistant
id: arcade_power
entity_id: sensor.arcade_automat_leistung
internal: true
- platform: homeassistant
id: dishwasher_power
entity_id: sensor.spulmaschine_leistung
internal: true
- platform: homeassistant
id: microwave_power
entity_id: sensor.waschetrockner_leistung
internal: true
# These Home Assistant template sensors already report decimal Mbps.
- platform: homeassistant
id: internet_download
entity_id: sensor.internet_speed_in
internal: true
- platform: homeassistant
id: internet_upload
entity_id: sensor.internet_speed_out
internal: true
# Utility meters inherit the SNMP counters' byte (B) unit.
- platform: homeassistant
id: internet_download_daily
entity_id: sensor.daily_internet_traffic_in
internal: true
- platform: homeassistant
id: internet_upload_daily
entity_id: sensor.daily_internet_traffic_out
internal: true
- platform: homeassistant
id: internet_download_monthly
entity_id: sensor.monthly_internet_traffic_in
internal: true
- platform: homeassistant
id: internet_upload_monthly
entity_id: sensor.monthly_internet_traffic_out
internal: true
- platform: wireguard
latest_handshake:
name: 'WireGuard Latest Handshake'
interval:
- interval: 1s
then:
- script.execute: refresh_display
- interval: 10s
then:
- lambda: |-
// Ignore log-only API clients when checking Home Assistant connectivity.
const bool connected = id(api_server).is_connected_with_state_subscription();
const int slot = id(internet_history_next);
const float download = id(internet_download).state;
const float upload = id(internet_upload).state;
// Keep disconnected/invalid samples as gaps rather than false zero traffic.
id(internet_download_history)[slot] = connected && id(internet_download).has_state()
&& std::isfinite(download) && download >= 0.0f ? download : NAN;
id(internet_upload_history)[slot] = connected && id(internet_upload).has_state()
&& std::isfinite(upload) && upload >= 0.0f ? upload : NAN;
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:
# The partial-buffer driver draws multiple strips; freeze time for the entire frame.
- lambda: id(display_time) = id(sntp_time).now();
- component.update: tft
- id: show_previous_page
then:
- lambda: id(page_index) = (id(page_index) + 5 - 1) % 5;
- script.execute: refresh_display
- id: toggle_page_mode
then:
- lambda: |-
if (id(page_index) == 0) {
id(page_one_gauge_view) = !id(page_one_gauge_view);
} else if (id(page_index) == 1) {
id(device_usage_view) = (id(device_usage_view) + 1) % 2;
} else if (id(page_index) == 2) {
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) % 5;
- script.execute: refresh_display
button:
- platform: template
name: "Zurück"
icon: mdi:arrow-left
on_press:
- script.execute: show_previous_page
- platform: template
name: "Modus"
icon: mdi:view-dashboard-outline
on_press:
- script.execute: toggle_page_mode
- platform: template
name: "Weiter"
icon: mdi:arrow-right
on_press:
- script.execute: show_next_page
binary_sensor:
- platform: wireguard
status:
name: 'WireGuard Status'
- platform: wireguard
enabled:
name: 'WireGuard Enabled'
# M5Stack Core button B (middle) switches the current page's view.
- platform: gpio
pin:
number: GPIO38
mode: INPUT
inverted: true
id: button_middle
on_press:
- script.execute: toggle_page_mode
# M5Stack Core button A (left). GPIO39 is input-only and has an external pull-up.
- platform: gpio
pin:
number: GPIO39
mode: INPUT
inverted: true
id: button_left
on_press:
- script.execute: show_previous_page
# M5Stack Core button C (right). GPIO37 is input-only and has an external pull-up.
- platform: gpio
pin:
number: GPIO37
mode: INPUT
inverted: true
id: button_right
on_press:
- script.execute: show_next_page
text_sensor:
- platform: wireguard
address:
name: 'WireGuard Address'
# M5CORE is the mipi_spi preset for the M5Stack Core's integrated display.
display:
- platform: mipi_spi
id: tft
model: M5CORE
cs_pin: GPIO14
dc_pin: GPIO27
reset_pin: GPIO33
invert_colors: true
update_interval: never
lambda: |-
const auto now = id(display_time);
it.fill(Color::BLACK);
if (now.is_valid()) {
it.strftime(234, 10, id(font_small), Color::WHITE, TextAlign::TOP_RIGHT,
"%H:%M", now);
} else {
it.printf(234, 10, id(font_small), Color::WHITE, TextAlign::TOP_RIGHT, "--:--");
}
// Native API connectivity; Home Assistant is normally the connected client.
const bool home_assistant_online = id(api_server).is_connected();
const Color home_assistant_color = home_assistant_online ? Color(64, 255, 64) : Color(255, 80, 80);
it.printf(260, 8, id(font_icon), home_assistant_color, TextAlign::TOP_RIGHT, "\U000F07D0");
// Green means the WireGuard peer is online; red means it is offline.
const bool wireguard_online = id(energymonitor_wireguard).is_peer_up();
const Color wireguard_color = wireguard_online ? Color(64, 255, 64) : Color(255, 80, 80);
it.printf(286, 8, id(font_icon), wireguard_color, TextAlign::TOP_RIGHT, "\U000F0306");
// Render the requested MDI Wi-Fi strength glyph in green or red when disconnected.
const bool wifi_online = id(wifi_connected);
const Color wifi_color = wifi_online ? Color(64, 255, 64) : Color(255, 80, 80);
const char *wifi_icon = "\U000F092D"; // mdi:wifi-strength-off
if (wifi_online) {
if (id(wifisignal).has_state()) {
const float rssi = id(wifisignal).state;
wifi_icon = rssi >= -55 ? "\U000F0928" : rssi >= -67 ? "\U000F0925"
: rssi >= -75 ? "\U000F0922" : "\U000F091F";
} else {
wifi_icon = "\U000F091F";
}
}
it.printf(312, 8, id(font_icon), wifi_color, TextAlign::TOP_RIGHT, "%s", wifi_icon);
auto draw_page_title = [&](const char *title) {
it.print(12, 10, id(font_small), Color(143, 190, 223), title);
};
// Round live gauges up to a 1/2/5 scale, with a unit-specific minimum.
auto gauge_maximum = [](float value, float minimum) -> float {
const float peak = fmaxf(value, minimum);
const float magnitude = powf(10.0f, floorf(log10f(peak)));
const float normalized = peak / magnitude;
return magnitude * (normalized <= 1 ? 1 : normalized <= 2 ? 2
: normalized <= 5 ? 5 : 10);
};
if (id(page_index) == 0 && !id(page_one_gauge_view)) {
draw_page_title("Netzleistung");
if (id(grid_power).has_state()) {
const Color grid_power_color = id(grid_power).state >= 0
? Color(143, 190, 223) // #8FBEDF: positive grid power
: Color(150, 110, 217); // #966ED9: negative grid power
it.printf(308, 32, id(font_value), grid_power_color, TextAlign::TOP_RIGHT,
"%+.0f W", id(grid_power).state);
} else {
it.printf(308, 42, id(font_value), Color::WHITE, TextAlign::TOP_RIGHT,
"-- W");
}
if (id(grid_import_energy_daily).has_state()) {
it.printf(12, 96, id(font_small), Color::WHITE, "Bezug: %.2f kWh",
id(grid_import_energy_daily).state);
} else {
it.printf(12, 96, id(font_small), Color::WHITE, "Bezug: -- kWh");
}
if (id(grid_export_energy_daily).has_state()) {
it.printf(12, 116, id(font_small), Color::WHITE, "Einspeisung: %.2f kWh",
id(grid_export_energy_daily).state);
} else {
it.printf(12, 116, id(font_small), Color::WHITE, "Einspeisung: -- kWh");
}
it.printf(12, 138, id(font_small), Color::WHITE, "Balkonkraftwerk");
// Daily solar energy is aligned to the left of the current solar-power reading.
if (id(solar_energy_daily).has_state()) {
it.printf(12, 180, id(font_small), Color(255, 170, 40), "Solar: %.2f kWh",
id(solar_energy_daily).state);
} else {
it.printf(12, 180, id(font_small), Color(255, 170, 40), "Solar: -- kWh");
}
if (id(balcony_solar_power).has_state()) {
it.printf(308, 158, id(font_value), Color(255, 170, 40), TextAlign::TOP_RIGHT,
"%.0f W", id(balcony_solar_power).state);
} else {
it.printf(308, 168, id(font_value), Color(255, 170, 40), TextAlign::TOP_RIGHT,
"-- W");
}
} else if (id(page_index) == 0) {
draw_page_title("Energie");
// One shared gauge with two parallel curved bars: grid on the outside, solar inside.
const Color gauge_background = Color(42, 42, 42);
const int gauge_center_x = 160;
const int gauge_center_y = 188;
// The outer band represents the absolute grid-power value with its signed color.
const bool grid_power_valid = id(grid_power).has_state() && std::isfinite(id(grid_power).state);
const float grid_power_value = grid_power_valid ? id(grid_power).state : 0.0f;
const float grid_power_magnitude = fabsf(grid_power_value);
const float grid_maximum = gauge_maximum(grid_power_magnitude, 1.0f);
int grid_gauge_percent = static_cast<int>(grid_power_magnitude / grid_maximum * 100.0f);
if (grid_gauge_percent > 100) grid_gauge_percent = 100;
const Color grid_gauge_color = grid_power_value >= 0.0f
? Color(143, 190, 223) // #8FBEDF
: Color(150, 110, 217); // #966ED9
it.filled_gauge(gauge_center_x, gauge_center_y, 140, 122, 100, gauge_background);
it.filled_gauge(gauge_center_x, gauge_center_y, 140, 122, grid_gauge_percent, grid_gauge_color);
// The inner band scales independently to the current solar power.
const bool solar_power_valid = id(balcony_solar_power).has_state()
&& std::isfinite(id(balcony_solar_power).state) && id(balcony_solar_power).state >= 0.0f;
const float solar_power_value = solar_power_valid ? id(balcony_solar_power).state : 0.0f;
const float solar_maximum = gauge_maximum(solar_power_value, 1.0f);
int solar_gauge_percent = static_cast<int>(solar_power_value / solar_maximum * 100.0f);
if (solar_gauge_percent > 100) solar_gauge_percent = 100;
const Color solar_gauge_color = Color(255, 170, 40); // #FFAA28
it.filled_gauge(gauge_center_x, gauge_center_y, 116, 98, 100, gauge_background);
it.filled_gauge(gauge_center_x, gauge_center_y, 116, 98, solar_gauge_percent, solar_gauge_color);
it.printf(gauge_center_x, 102, id(font_small), Color::WHITE, TextAlign::TOP_CENTER,
"Netzleistung");
if (grid_power_valid) {
it.printf(gauge_center_x, 120, id(font_title), grid_gauge_color, TextAlign::TOP_CENTER,
"%+.0f W", grid_power_value);
} else {
it.printf(gauge_center_x, 120, id(font_title), Color::WHITE, TextAlign::TOP_CENTER, "-- W");
}
it.printf(gauge_center_x, 151, id(font_small), Color::WHITE, TextAlign::TOP_CENTER,
"Balkonkraftwerk");
if (solar_power_valid) {
it.printf(gauge_center_x, 169, id(font_title), solar_gauge_color, TextAlign::TOP_CENTER,
"%.0f W", solar_power_value);
} else {
it.printf(gauge_center_x, 169, id(font_title), solar_gauge_color, TextAlign::TOP_CENTER, "-- W");
}
auto format_power = [](float watts) -> std::string {
char text[32];
snprintf(text, sizeof(text), "%.0f W", watts);
return std::string(text);
};
it.printf(gauge_center_x, 204, id(font_device), Color::WHITE, TextAlign::TOP_CENTER,
"Max: Netz %s / Solar %s",
grid_power_valid ? format_power(grid_maximum).c_str() : "--",
solar_power_valid ? format_power(solar_maximum).c_str() : "--");
} else if (id(page_index) == 1) {
// Page 2 lists nine consumers at once in two columns; button B changes group.
const int device_count = 18;
const int usage_group = id(device_usage_view);
const int page_start = usage_group * 9;
const char *device_names[device_count] = {
"Raumduft", "Keller", "Anrichte", "Musik", "Bett", "HK Schlafzimmer",
"Deko", "Schreibtisch", "Serverraum", "HK Wohnzimmer", "Spieleschrank",
"Kühlschrank", "Kaffeemaschine", "Wäschetrockner", "Waschmaschine", "Arcade",
"Spülmaschine", "Mikrowelle"};
float device_values[device_count] = {
id(hallway_fragrance_power).state, id(cellar_power).state, id(sideboard_power).state,
id(kitchen_music_power).state, id(bedroom_bed_power).state, id(bedroom_cinema_power).state,
id(desk_decoration_power).state, id(desk_power).state, id(server_room_power).state,
id(living_room_cinema_power).state, id(games_cabinet_power).state, id(refrigerator_power).state,
id(coffee_machine_power).state, id(dryer_power).state, id(washing_machine_power).state,
id(arcade_power).state, id(dishwasher_power).state, id(microwave_power).state};
bool device_has_state[device_count] = {
id(hallway_fragrance_power).has_state(), id(cellar_power).has_state(), id(sideboard_power).has_state(),
id(kitchen_music_power).has_state(), id(bedroom_bed_power).has_state(), id(bedroom_cinema_power).has_state(),
id(desk_decoration_power).has_state(), id(desk_power).has_state(), id(server_room_power).has_state(),
id(living_room_cinema_power).has_state(), id(games_cabinet_power).has_state(), id(refrigerator_power).has_state(),
id(coffee_machine_power).has_state(), id(dryer_power).has_state(), id(washing_machine_power).has_state(),
id(arcade_power).has_state(), id(dishwasher_power).has_state(), id(microwave_power).has_state()};
Color device_colors[device_count] = {
Color(255, 170, 40), Color(69, 189, 255), Color(170, 116, 217), Color(93, 207, 140),
Color(238, 112, 112), Color(107, 139, 255), Color(255, 130, 180), Color(122, 204, 174),
Color(240, 195, 90), Color(151, 120, 220), Color(91, 189, 193), Color(212, 132, 82),
Color(143, 190, 223), Color(233, 106, 142), Color(132, 205, 92), Color(255, 181, 80),
Color(111, 167, 229), Color(190, 137, 226)};
// The title uses the free area to the left of the status indicators.
draw_page_title("Geräteverbrauch");
for (int display_index = 0; display_index < 9; display_index++) {
const int index = page_start + display_index;
const int column = display_index % 2;
const int row = display_index / 2;
const int x = column == 0 ? 12 : 166;
const int y = 43 + row * 25;
if (device_has_state[index]) {
it.printf(x, y, id(font_device), device_colors[index], "%s: %.0f W",
device_names[index], device_values[index]);
} else {
it.printf(x, y, id(font_device), device_colors[index], "%s: -- W", device_names[index]);
}
}
// The stacked bar always represents all 18 consumer devices, not only this view's rows.
const int bar_x = 12;
const int bar_y = 180;
const int bar_width = 296;
const int bar_height = 18;
float usage_sum = 0.0f;
int last_active = -1;
for (int index = 0; index < device_count; index++) {
if (device_has_state[index] && device_values[index] > 0.0f) {
usage_sum += device_values[index];
last_active = index;
}
}
it.filled_rectangle(bar_x, bar_y, bar_width, bar_height, Color(42, 42, 42));
if (usage_sum > 0.0f) {
int cursor_x = bar_x;
for (int index = 0; index < device_count; index++) {
if (!device_has_state[index] || device_values[index] <= 0.0f) continue;
const int segment_width = index == last_active
? bar_x + bar_width - cursor_x
: static_cast<int>(device_values[index] * bar_width / usage_sum);
if (segment_width > 0) {
it.filled_rectangle(cursor_x, bar_y, segment_width, bar_height, device_colors[index]);
cursor_x += segment_width;
}
}
}
}
if (id(page_index) == 2) {
const Color accent(143, 190, 223);
const Color gold(255, 170, 40);
const Color muted(55, 65, 80);
draw_page_title("Uhr · Berlin");
if (!now.is_valid()) {
it.print(160, 100, id(font_title), accent, TextAlign::TOP_CENTER, "--:--:--");
it.print(160, 190, id(font_small), Color::WHITE, TextAlign::TOP_CENTER,
"Warte auf Zeitsynchronisation");
} else {
if (!id(clock_digital_view)) {
const int cx = 160, cy = 111, radius = 74;
const float tau = 6.28318530718f;
it.filled_circle(cx, cy, radius, Color(12, 18, 28));
it.circle(cx, cy, radius, accent);
it.circle(cx, cy, radius - 3, muted);
for (int tick = 0; tick < 60; tick++) {
const float angle = tick * tau / 60.0f;
const int inner = tick % 5 == 0 ? radius - 13 : radius - 8;
it.line(cx + lroundf(sinf(angle) * inner), cy - lroundf(cosf(angle) * inner),
cx + lroundf(sinf(angle) * (radius - 5)),
cy - lroundf(cosf(angle) * (radius - 5)),
tick % 5 == 0 ? accent : muted);
}
it.print(cx, cy - 49, id(font_device), Color::WHITE, TextAlign::CENTER, "12");
it.print(cx + 50, cy, id(font_device), Color::WHITE, TextAlign::CENTER, "3");
it.print(cx, cy + 49, id(font_device), Color::WHITE, TextAlign::CENTER, "6");
it.print(cx - 50, cy, id(font_device), Color::WHITE, TextAlign::CENTER, "9");
// Round-ended hands; minutes and hours advance with the seconds.
auto hand = [&](float turns, int length, int thickness, Color color) {
const float angle = turns * tau;
for (int r = 0; r <= length; r++) {
it.filled_circle(cx + lroundf(sinf(angle) * r),
cy - lroundf(cosf(angle) * r), thickness, color);
}
};
hand((now.hour % 12 + now.minute / 60.0f + now.second / 3600.0f) / 12.0f,
34, 3, Color::WHITE);
hand((now.minute + now.second / 60.0f) / 60.0f, 51, 2, accent);
const float second_angle = now.second * tau / 60.0f;
it.line(cx - lroundf(sinf(second_angle) * 12), cy + lroundf(cosf(second_angle) * 12),
cx + lroundf(sinf(second_angle) * 60), cy - lroundf(cosf(second_angle) * 60), gold);
it.filled_circle(cx, cy, 4, gold);
it.filled_circle(cx, cy, 1, Color::BLACK);
} else {
// Draw seven-segment digits directly, avoiding an extra font dependency.
const uint8_t masks[10] = {0x3F, 0x06, 0x5B, 0x4F, 0x66,
0x6D, 0x7D, 0x07, 0x7F, 0x6F};
const int digits[6] = {now.hour / 10, now.hour % 10, now.minute / 10,
now.minute % 10, now.second / 10, now.second % 10};
const int positions[6] = {17, 61, 121, 165, 225, 269};
const int y = 77;
for (int digit = 0; digit < 6; digit++) {
const int x = positions[digit];
const Color lit = digit < 4 ? accent : gold;
for (int segment = 0; segment < 7; segment++) {
const Color color = (masks[digits[digit]] & (1 << segment))
? lit : Color(20, 27, 36);
const bool horizontal = segment == 0 || segment == 3 || segment == 6;
const int sx = horizontal ? x + 6 : x + ((segment == 1 || segment == 2) ? 30 : 0);
const int sy = y + (segment == 0 ? 0 : segment == 3 ? 72 : segment == 6 ? 36
: (segment == 1 || segment == 5) ? 6 : 42);
// Taper each segment's ends for the classic LED clock shape.
for (int stripe = 0; stripe < 6; stripe++) {
const int inset = stripe < 3 ? 2 - stripe : stripe - 3;
if (horizontal)
it.line(sx + inset, sy + stripe, sx + 23 - inset, sy + stripe, color);
else
it.line(sx + stripe, sy + inset, sx + stripe, sy + 29 - inset, color);
}
}
}
for (int x : {109, 213}) {
it.filled_circle(x, y + 24, 3, accent);
it.filled_circle(x, y + 54, 3, accent);
}
it.line(40, 172, 280, 172, muted);
}
const char *weekdays[] = {"Sonntag", "Montag", "Dienstag", "Mittwoch",
"Donnerstag", "Freitag", "Samstag"};
it.printf(160, 190, id(font_small), Color::WHITE, TextAlign::TOP_CENTER,
"%s, %02d.%02d.%04d", weekdays[now.day_of_week - 1],
now.day_of_month, now.month, now.year);
}
}
if (id(page_index) == 3) {
const Color download_color(143, 190, 223);
const Color upload_color(255, 170, 40);
const Color muted(42, 42, 42);
const bool connected = id(api_server).is_connected_with_state_subscription();
const float download = id(internet_download).state;
const float upload = id(internet_upload).state;
const bool download_valid = connected && id(internet_download).has_state()
&& std::isfinite(download) && download >= 0.0f;
const bool upload_valid = connected && id(internet_upload).has_state()
&& std::isfinite(upload) && upload >= 0.0f;
auto format_rate = [](float mbps) -> std::string {
char text[32];
if (mbps < 1.0f)
snprintf(text, sizeof(text), "%.0f kbps", mbps * 1000.0f);
else
snprintf(text, sizeof(text), "%.1f Mbps", mbps);
return std::string(text);
};
if (id(internet_view) == 0) {
draw_page_title("Internet");
const int cx = 160, cy = 188;
const float download_maximum = gauge_maximum(download_valid ? download : 0.0f, 0.001f);
const float upload_maximum = gauge_maximum(upload_valid ? upload : 0.0f, 0.001f);
const float download_fraction = download_valid
? fminf(download / download_maximum, 1.0f) : 0.0f;
const float upload_fraction = upload_valid
? fminf(upload / upload_maximum, 1.0f) : 0.0f;
// Match the energy gauge: download outside, upload inside.
it.filled_gauge(cx, cy, 140, 122, 100, muted);
it.filled_gauge(cx, cy, 140, 122, static_cast<int>(download_fraction * 100), download_color);
it.filled_gauge(cx, cy, 116, 98, 100, muted);
it.filled_gauge(cx, cy, 116, 98, static_cast<int>(upload_fraction * 100), upload_color);
it.print(cx, 102, id(font_small), Color::WHITE, TextAlign::TOP_CENTER, "Download");
it.printf(cx, 120, id(font_internet_value), download_color, TextAlign::TOP_CENTER,
"%s", download_valid ? format_rate(download).c_str() : "--");
it.print(cx, 151, id(font_small), Color::WHITE, TextAlign::TOP_CENTER, "Upload");
it.printf(cx, 169, id(font_internet_value), upload_color, TextAlign::TOP_CENTER,
"%s", upload_valid ? format_rate(upload).c_str() : "--");
it.printf(cx, 204, id(font_device), Color::WHITE, TextAlign::TOP_CENTER,
"Max: DL %s / UL %s",
download_valid ? format_rate(download_maximum).c_str() : "--",
upload_valid ? format_rate(upload_maximum).c_str() : "--");
} else if (id(internet_view) == 1) {
draw_page_title("Internet · 1 h");
const int left = 28, right = 308, top = 62, zero = 122, bottom = 182;
const int count = id(internet_history_count);
// A shared symmetric scale keeps upload/download magnitudes comparable.
float peak = 0.001f;
for (int sample = 0; sample < count; sample++) {
const int slot = (id(internet_history_next) - count + sample + 361) % 361;
const float down = id(internet_download_history)[slot];
const float up = id(internet_upload_history)[slot];
if (std::isfinite(down) && down > peak) peak = down;
if (std::isfinite(up) && up > peak) peak = up;
}
const float magnitude = powf(10.0f, floorf(log10f(peak)));
const float normalized = peak / magnitude;
const float maximum = magnitude * (normalized <= 1 ? 1 : normalized <= 2 ? 2
: normalized <= 5 ? 5 : 10);
const std::string scale = format_rate(maximum);
it.print(left, 40, id(font_device), upload_color, "Upload");
it.printf(right, 40, id(font_device), upload_color, TextAlign::TOP_RIGHT,
"+%s", scale.c_str());
it.print(left, 184, id(font_device), download_color, "Download");
it.printf(right, 184, id(font_device), download_color, TextAlign::TOP_RIGHT,
"-%s", scale.c_str());
for (int minute = 0; minute <= 60; minute += 15) {
const int x = left + minute * (right - left) / 60;
it.line(x, top, x, bottom, muted);
}
it.line(left, top, right, top, muted);
it.line(left, bottom, right, bottom, muted);
it.line(left, zero, right, zero, Color(100, 100, 100));
it.print(12, zero, id(font_device), Color::WHITE, TextAlign::CENTER, "0");
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");
int previous_x = 0, previous_down_y = 0, previous_up_y = 0;
bool previous_down_valid = false, previous_up_valid = false;
for (int sample = 0; sample < count; sample++) {
const int slot = (id(internet_history_next) - count + sample + 361) % 361;
// Right-align a partially filled buffer; do not invent pre-boot history.
const int x = right - (count - 1 - sample) * (right - left) / 360;
const float down = id(internet_download_history)[slot];
const float up = id(internet_upload_history)[slot];
const bool down_valid = std::isfinite(down);
const bool up_valid = std::isfinite(up);
const int down_y = down_valid ? zero + lroundf(down / maximum * (bottom - zero)) : zero;
const int up_y = up_valid ? zero - lroundf(up / maximum * (zero - top)) : zero;
if (down_valid) {
it.line(previous_down_valid ? previous_x : x,
previous_down_valid ? previous_down_y : down_y, x, down_y, download_color);
}
if (up_valid) {
it.line(previous_up_valid ? previous_x : x,
previous_up_valid ? previous_up_y : up_y, x, up_y, upload_color);
}
previous_x = x;
previous_down_y = down_y;
previous_up_y = up_y;
previous_down_valid = down_valid;
previous_up_valid = up_valid;
}
if (count == 0) {
it.print(160, 90, id(font_small), Color::WHITE, TextAlign::TOP_CENTER,
"Warte auf Messwerte");
}
} else {
draw_page_title("Internet · Volumen");
auto format_volume = [&](sensor::Sensor *source) -> std::string {
if (!connected || !source->has_state() || !std::isfinite(source->state)
|| source->state < 0.0f) return "--";
const char *units[] = {"B", "kB", "MB", "GB", "TB"};
double volume = source->state;
int unit = 0;
while (volume >= 1000.0 && unit < 4) {
volume /= 1000.0;
unit++;
}
char text[32];
snprintf(text, sizeof(text), "%.2f %s", volume, units[unit]);
return std::string(text);
};
it.print(108, 40, id(font_small), download_color, TextAlign::TOP_CENTER, "Download");
it.print(244, 40, id(font_small), upload_color, TextAlign::TOP_CENTER, "Upload");
const char *periods[] = {"Heute", "Dieser Monat"};
sensor::Sensor *downloads[] = {id(internet_download_daily), id(internet_download_monthly)};
sensor::Sensor *uploads[] = {id(internet_upload_daily), id(internet_upload_monthly)};
for (int period = 0; period < 2; period++) {
const int y = 72 + period * 72;
it.print(12, y, id(font_small), Color::WHITE, periods[period]);
it.printf(108, y + 22, id(font_internet_value), download_color, TextAlign::TOP_CENTER,
"%s", format_volume(downloads[period]).c_str());
it.printf(244, y + 22, id(font_internet_value), upload_color, TextAlign::TOP_CENTER,
"%s", format_volume(uploads[period]).c_str());
}
it.line(12, 132, 308, 132, muted);
}
}
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");
it.printf(312, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_RIGHT,
"Weiter >");
if (id(page_index) == 0) {
it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER,
"%s · %d / 2", id(page_one_gauge_view) ? "Tacho" : "Werte",
id(page_one_gauge_view) ? 2 : 1);
} else if (id(page_index) == 1) {
it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER,
"Geräte · %d / 2", id(device_usage_view) + 1);
} else if (id(page_index) == 2) {
it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER,
"%s · %d / 2", id(clock_digital_view) ? "Digital" : "Analog",
id(clock_digital_view) ? 2 : 1);
} else if (id(page_index) == 3) {
it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER,
"%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);
}