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" co2_beep_gain: "1%" 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 # The original M5Stack Core's speaker amplifier is connected to GPIO25. - platform: ledc pin: GPIO25 id: speaker_output frequency: 880Hz rtttl: id: co2_tones output: speaker_output gain: ${co2_beep_gain} 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: co2_alert_level type: int restore_value: false initial_value: '-1' - 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 restore_value: false initial_value: 'std::array{}' - id: internet_upload_history type: std::array restore_value: false initial_value: 'std::array{}' - 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 on_value: then: - lambda: |- if (!std::isfinite(x)) return; const int level = x > 1600.0f ? 2 : x > 1200.0f ? 1 : 0; const int previous_level = id(co2_alert_level); id(co2_alert_level) = level; // Establish a silent baseline at startup; only upward crossings beep. if (previous_level < 0 || level <= previous_level) return; id(co2_tones).stop(); id(co2_tones).play(level == 2 ? "co2high:d=16,o=5,b=150:a,p,a" : "co2warn:d=16,o=5,b=150:a"); 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(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(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(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(download_fraction * 100), download_color); it.filled_gauge(cx, cy, 116, 98, 100, muted); it.filled_gauge(cx, cy, 116, 98, static_cast(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, "Feuchtigkeit"); 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, top = 40, bottom = 142; const int count = id(climate_history_count); const char *labels[] = {"CO2", "Temperatur", "Feuchtigkeit"}; 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 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)); for (int graph = 0; graph < 3; graph++) { const int legend_y = 168 + graph * 16; 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, legend_y, id(font_device), colors[graph], labels[graph]); if (has_samples) { it.printf(right, legend_y, id(font_device), colors[graph], TextAlign::TOP_RIGHT, "%.0f - %.0f %s", minimum, maximum, units[graph]); } else { it.print(right, legend_y, id(font_device), colors[graph], TextAlign::TOP_RIGHT, "Warte auf Messwerte"); } 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, 144, id(font_device), Color::WHITE, "-60 min"); it.print((left + right) / 2, 144, id(font_device), Color::WHITE, TextAlign::TOP_CENTER, "-30 min"); it.print(right, 144, 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); }