substitutions: device_description: "M5Stack Core energy dashboard for Home Assistant grid, solar and consumer usage, with analog/digital clock and WireGuard" name: "energymonitor" friendly_name: "Energy Monitor" grid_power_gauge_max_w: "5000.0" balcony_solar_gauge_max_w: "800.0" 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 password: !secret ota spi: clk_pin: GPIO18 mosi_pin: GPIO23 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-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: 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' sensor: # 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 - platform: wireguard latest_handshake: name: 'WireGuard Latest Handshake' script: - id: show_previous_page then: - lambda: id(page_index) = (id(page_index) + 3 - 1) % 3; - component.update: tft - 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 { id(clock_digital_view) = !id(clock_digital_view); } - component.update: tft - id: show_next_page then: - lambda: id(page_index) = (id(page_index) + 1) % 3; - component.update: tft 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: 1s lambda: |- it.fill(Color::BLACK); if (id(sntp_time).now().is_valid()) { it.strftime(234, 10, id(font_small), Color::WHITE, TextAlign::TOP_RIGHT, "%H:%M", id(sntp_time).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); if (id(page_index) == 0 && !id(page_one_gauge_view)) { it.printf(12, 12, id(font_small), Color::WHITE, "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) { // 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. float grid_power_value = id(grid_power).has_state() ? id(grid_power).state : 0.0f; float grid_power_magnitude = grid_power_value < 0.0f ? -grid_power_value : grid_power_value; int grid_gauge_percent = static_cast(grid_power_magnitude * 100.0f / ${grid_power_gauge_max_w}); 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 is solar power, normalized against the 800 W inverter maximum. float solar_power_value = id(balcony_solar_power).has_state() ? id(balcony_solar_power).state : 0.0f; int solar_gauge_percent = static_cast(solar_power_value * 100.0f / ${balcony_solar_gauge_max_w}); if (solar_gauge_percent < 0) solar_gauge_percent = 0; 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 (id(grid_power).has_state()) { 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 (id(balcony_solar_power).has_state()) { 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"); } } 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. it.printf(12, 10, id(font_device), Color::WHITE, "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 auto now = id(sntp_time).now(); const Color accent(143, 190, 223); const Color gold(255, 170, 40); const Color muted(55, 65, 80); it.print(12, 10, id(font_device), accent, "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); } } // 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, "Ansicht · 1 / 3"); } 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 { it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER, "%s · 3 / 3", id(clock_digital_view) ? "Analog" : "Digital"); }