substitutions: name: "energymonitor" friendly_name: "Energy Monitor" grid_power_gauge_max_w: "5000.0" balcony_solar_gauge_max_w: "800.0" esphome: 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: 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) + 2 - 1) % 2; - 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 { id(device_usage_view) = (id(device_usage_view) + 1) % 2; } - component.update: tft - id: show_next_page then: - lambda: id(page_index) = (id(page_index) + 1) % 2; - 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) toggles the alternate gauge view on page one. - 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 { // 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; } } } } // 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 / 2"); } else { it.printf(160, 239, id(font_small), Color::WHITE, TextAlign::BOTTOM_CENTER, "Geräte %d / 2", id(device_usage_view) + 1); }