diff --git a/esphome/.env.template b/esphome/.env.template new file mode 100644 index 0000000..c957877 --- /dev/null +++ b/esphome/.env.template @@ -0,0 +1,3 @@ +# Wi-Fi credentials for ESPHome devices +WIFI_SSID="your_wifi_ssid" +WIFI_PASSWORD="your_wifi_password" diff --git a/esphome/.gitignore b/esphome/.gitignore new file mode 100644 index 0000000..b0b3d72 --- /dev/null +++ b/esphome/.gitignore @@ -0,0 +1,2 @@ +.env +.esphome/ diff --git a/esphome/AGENTS.md b/esphome/AGENTS.md new file mode 100644 index 0000000..76711e8 --- /dev/null +++ b/esphome/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md (ESPHome Workspace) + +This directory is a self-contained ESPHome project space utilizing dynamic configuration loaders and an interactive flashing CLI. + +## Rules & Design Standards + +### Secret Management +* **NEVER** write plaintext Wi-Fi passwords, tokens, or API keys directly in the YAML configurations. +* **DO NOT** modify the `.gitignore` to allow checking in `.env` files. +* Use ESPHome's `!env_var VAR_NAME` to inject environment variables at compile-time. Any new secrets must be declared in `.env.template` and configured locally in `.env`. + +### Configuration Standards +* Default board architecture is ESP32 (`nodemcu-32s` with `arduino` framework) unless specified. +* Naming conventions for configurations: `esdisplay_.yaml` or `sensor_.yaml`. +* All displays should use the `time` component with SNTP sync for accurate time rendering. +* Prefer importing sensors from Home Assistant over Native API client integrations, as it decouples rendering layouts from automation scripts. + +### Build and Run Tools +* Use `uv run flash.py` to compile, validate, clean, or flash ESPHome configurations. +* Avoid triggering direct `esphome` cli calls unless doing isolated validation tests. diff --git a/esphome/README.md b/esphome/README.md new file mode 100644 index 0000000..fd82391 --- /dev/null +++ b/esphome/README.md @@ -0,0 +1,48 @@ +# ESPHome Use-Cases & e-Paper Display + +This directory contains ESPHome configuration files for various IoT and monitoring devices, using environment variables to keep credentials local and gitignored. + +## Get Started + +### 1. Configure Credentials +Copy `.env.template` to `.env` and fill in your Wi-Fi credentials: +```bash +cp .env.template .env +``` +Inside `.env`, configure: +- `WIFI_SSID`: Your local Wi-Fi Name. +- `WIFI_PASSWORD`: Your local Wi-Fi Password. + +### 2. Run the Flashing Wizard +Use the interactive Python script to flash your microcontroller: +```bash +uv run flash.py +``` +This wizard will prompt you to: +1. Select which `.yaml` configuration file to use. +2. Select the command to run (such as `Run`, `Compile`, `Upload`, `Logs`, `Validate`, or `Clean`). + +*Note: `uv run` handles the required dependencies (`questionary` and `python-dotenv`) automatically inside an isolated ephemeral environment, so there is no need to manually manage virtualenvs or pip installs.* + +--- + +## Home Assistant Integration + +The configurations in this directory connect directly to Home Assistant using the **ESPHome Native API**. + +### Dynamic Sensor Subscriptions +Instead of pushing raw screens from Home Assistant, we pull the required entities dynamically into ESPHome's internal variables. + +1. **Outdoor Temperature (`sensor.regensburg_regensburg_temperatur`)**: + Pulled automatically via the `homeassistant` platform sensor. +2. **Kitchen Temperature (`sensor.home_kuche_wandthermostat_temperature`)**: + Pulled automatically from the kitchen wall thermostat. +3. **Kitchen Message (`input_text.epaper_message`)**: + A Home Assistant `input_text` helper. When the state changes, Home Assistant pushes the new text to the display. + +### Sending Messages from Command Line +You can push text warnings or messages directly to the display from the terminal using the helper script: +```bash +./send_message.sh "Your custom display message here" +``` +This script reads the long-lived API token from the `k8s/home-assistant` secrets folder to authenticate against the Home Assistant API automatically. diff --git a/esphome/esdisplay_kitchen.yaml b/esphome/esdisplay_kitchen.yaml new file mode 100644 index 0000000..2c65c9a --- /dev/null +++ b/esphome/esdisplay_kitchen.yaml @@ -0,0 +1,271 @@ +esphome: + name: esdisplay-kitchen + comment: "e-Paper display in the kitchen connected to Home Assistant" + platformio_options: + lib_deps: + - bblanchon/ArduinoJson@7.4.2 + includes: + - prometheus_client.h + +globals: + - id: prometheus_connected + type: bool + restore_value: no + initial_value: 'true' + +esp32: + board: nodemcu-32s + framework: + type: arduino + +# Enable logging +logger: + +# Enable Home Assistant API (auto-discovery) +api: + password: "" + +# Over-The-Air updates +ota: + platform: esphome + +wifi: + ssid: !env_var WIFI_SSID + password: !env_var WIFI_PASSWORD + + # Enable fallback hotspot in case wifi fails + ap: + ssid: "Kitchen Display Fallback" + password: "fallbackpassword" + +captive_portal: + +# Time synchronization to get current time on display +time: + - platform: sntp + id: sntp_time + timezone: "Europe/Berlin" + +# Define local and imported sensors +sensor: + - platform: homeassistant + id: ha_outdoor_temp + entity_id: sensor.regensburg_regensburg_temperatur + internal: true + - platform: homeassistant + id: ha_kitchen_temp + entity_id: sensor.home_kuche_wandthermostat_temperature + internal: true + + # Local template sensors scraped from Prometheus + - platform: template + id: local_cellar_temp + name: "Kitchen Display Cellar Temperature Indoor" + unit_of_measurement: "°C" + accuracy_decimals: 1 + - platform: template + id: local_cellar_humi + name: "Kitchen Display Cellar Humidity Indoor" + unit_of_measurement: "%" + accuracy_decimals: 0 + - platform: template + id: local_cellar_dp + name: "Kitchen Display Cellar Dewpoint Indoor" + unit_of_measurement: "°C" + accuracy_decimals: 1 + - platform: template + id: local_cellar_out_temp + name: "Kitchen Display Cellar Temperature Outdoor" + unit_of_measurement: "°C" + accuracy_decimals: 1 + - platform: template + id: local_cellar_out_humi + name: "Kitchen Display Cellar Humidity Outdoor" + unit_of_measurement: "%" + accuracy_decimals: 0 + - platform: template + id: local_cellar_out_dp + name: "Kitchen Display Cellar Dewpoint Outdoor" + unit_of_measurement: "°C" + accuracy_decimals: 1 + - platform: template + id: local_fan_status + name: "Kitchen Display Cellar Fan Status" + accuracy_decimals: 0 + - platform: template + id: local_mold_critical + name: "Kitchen Display Cellar Mold Critical" + accuracy_decimals: 0 + - platform: template + id: local_fan_would_activate + name: "Kitchen Display Cellar Fan Activation Criteria" + accuracy_decimals: 0 + - platform: template + id: local_mold_threshold + name: "Kitchen Display Cellar Mold Threshold" + unit_of_measurement: "%" + accuracy_decimals: 0 + - platform: template + id: local_shelly_online + name: "Kitchen Display Cellar Shelly Online" + accuracy_decimals: 0 + +# Interval to scrape metrics from Prometheus every 30 seconds +interval: + - interval: 30s + then: + - lambda: |- + fetch_prometheus(); + +text_sensor: + - platform: homeassistant + id: ha_message + entity_id: input_text.epaper_message + internal: true + - platform: wifi_info + ip_address: + name: "IP Address" + id: wifi_ip + +# Font configuration (uses Google Fonts Roboto) +font: + - file: "gfonts://Roboto" + id: font_title + size: 16 + - file: "gfonts://Roboto" + id: font_body + size: 12 + +# SPI bus configuration for e-Paper display +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: waveshare_epaper + id: epaper_display + model: 2.13inv3 + cs_pin: GPIO5 + dc_pin: GPIO17 + reset_pin: GPIO16 + busy_pin: GPIO4 + rotation: 90 + update_interval: 60s + lambda: |- + // Fill background with white + it.fill(COLOR_OFF); + + // Check if Prometheus is reachable + if (!id(prometheus_connected)) { + it.print(125, 20, id(font_title), TextAlign::CENTER, "PROMETHEUS ERROR"); + it.print(125, 55, id(font_body), TextAlign::CENTER, "Cannot reach server"); + it.print(125, 75, id(font_body), TextAlign::CENTER, "Check network or auth"); + + // Show time at the bottom if synced + it.line(5, 96, 245, 96); + char time_str[30]; + auto time_now = id(sntp_time).now(); + if (time_now.is_valid()) { + it.strftime(5, 100, id(font_body), "Time: %a %H:%M", time_now); + } else { + it.print(5, 100, id(font_body), "Time: --:--"); + } + return; + } + + // --- SECTION 1: HEADER --- + it.print(5, 2, id(font_title), "Cellar Fan (TAUPI)"); + + // Time and weekday on the right + auto time_now = id(sntp_time).now(); + if (time_now.is_valid()) { + it.strftime(245, 2, id(font_body), TextAlign::TOP_RIGHT, "%a %H:%M", time_now); + } else { + it.print(245, 2, id(font_body), TextAlign::TOP_RIGHT, "Time: --:--"); + } + + // Draw header divider + it.line(5, 18, 245, 18); + + // --- SECTION 2: CLIMATE CELLS --- + // Vertical separator + it.line(122, 22, 122, 82); + + // CELLAR INDOOR (Left) + it.print(5, 22, id(font_body), "CELLAR INDOOR"); + + if (!isnan(id(local_cellar_temp).state)) { + it.printf(5, 36, id(font_body), "Temp: %.1f C", id(local_cellar_temp).state); + } else { + it.print(5, 36, id(font_body), "Temp: -- C"); + } + + if (!isnan(id(local_cellar_humi).state)) { + it.printf(5, 49, id(font_body), "Humi: %.0f%%", id(local_cellar_humi).state); + } else { + it.print(5, 49, id(font_body), "Humi: --%%"); + } + + if (!isnan(id(local_cellar_dp).state)) { + it.printf(5, 62, id(font_body), "Dewp: %.1f C", id(local_cellar_dp).state); + } else { + it.print(5, 62, id(font_body), "Dewp: -- C"); + } + + if (!isnan(id(local_mold_threshold).state)) { + it.printf(5, 75, id(font_body), "Limit: %.0f%%", id(local_mold_threshold).state); + } else { + it.print(5, 75, id(font_body), "Limit: --%%"); + } + + // CELLAR OUTDOOR (Right) + it.print(128, 22, id(font_body), "CELLAR OUTDOOR"); + + if (!isnan(id(local_cellar_out_temp).state)) { + it.printf(128, 36, id(font_body), "Temp: %.1f C", id(local_cellar_out_temp).state); + } else { + it.print(128, 36, id(font_body), "Temp: -- C"); + } + + if (!isnan(id(local_cellar_out_humi).state)) { + it.printf(128, 49, id(font_body), "Humi: %.0f%%", id(local_cellar_out_humi).state); + } else { + it.print(128, 49, id(font_body), "Humi: --%%"); + } + + if (!isnan(id(local_cellar_out_dp).state)) { + it.printf(128, 62, id(font_body), "Dewp: %.1f C", id(local_cellar_out_dp).state); + } else { + it.print(128, 62, id(font_body), "Dewp: -- C"); + } + + // --- SECTION 3: STATUS BAR (Bottom) --- + it.line(5, 86, 245, 86); + + // Fan status + const char* fan_state = "OFF"; + if (!isnan(id(local_fan_status).state) && id(local_fan_status).state > 0.5) { + fan_state = "ON"; + } + it.printf(5, 90, id(font_body), "Fan: %s", fan_state); + + // Mold status + const char* mold_state = "SAFE"; + if (!isnan(id(local_mold_critical).state) && id(local_mold_critical).state > 0.5) { + mold_state = "CRITICAL"; + } + it.printf(128, 90, id(font_body), "Mold: %s", mold_state); + + // Criteria status + const char* crit_state = "NO"; + if (!isnan(id(local_fan_would_activate).state) && id(local_fan_would_activate).state > 0.5) { + crit_state = "YES"; + } + it.printf(5, 104, id(font_body), "Criteria: %s", crit_state); + + // Shelly Status + const char* shelly_state = "OFFLINE"; + if (!isnan(id(local_shelly_online).state) && id(local_shelly_online).state > 0.5) { + shelly_state = "ONLINE"; + } + it.printf(128, 104, id(font_body), "Shelly: %s", shelly_state); diff --git a/esphome/flash.py b/esphome/flash.py new file mode 100755 index 0000000..dcfa2aa --- /dev/null +++ b/esphome/flash.py @@ -0,0 +1,142 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "python-dotenv", +# "questionary", +# ] +# /// + +import os +import sys +import subprocess +import shutil +from pathlib import Path +from dotenv import load_dotenv + +# Ensure we run from the script's directory +script_dir = Path(__file__).resolve().parent +os.chdir(script_dir) + +try: + import questionary +except ImportError: + print("[Error] Failed to import questionary. Run this script using 'uv run flash.py'.") + sys.exit(1) + +def run_esphome(args, env): + esphome_bin = shutil.which("esphome") + if not esphome_bin: + print("\n[Error] 'esphome' CLI not found on PATH.") + print("Please install it (e.g. 'brew install esphome' or 'pip install esphome').\n") + sys.exit(1) + + cmd = [esphome_bin] + args + print(f"\nExecuting: {' '.join(cmd)}\n") + try: + process = subprocess.Popen( + cmd, + env=env, + stdout=sys.stdout, + stderr=sys.stderr, + text=True + ) + process.wait() + return process.returncode + except KeyboardInterrupt: + print("\n\nExecution interrupted.") + return 1 + +def get_device_name(yaml_path): + try: + with open(yaml_path, "r") as f: + in_esphome = False + for line in f: + stripped = line.strip() + if stripped.startswith("esphome:"): + in_esphome = True + continue + if in_esphome: + if line.strip() and not line.startswith(" ") and not line.startswith("\t"): + break + if stripped.startswith("name:"): + name = stripped.split(":", 1)[1].split("#", 1)[0].strip() + return name.replace('"', '').replace("'", "") + except Exception: + pass + return None + +def main(): + env_file = script_dir / ".env" + template_file = script_dir / ".env.template" + + # Auto-copy template if .env is missing + if not env_file.exists(): + if template_file.exists(): + print(f"'.env' not found. Copying '.env.template' to '.env'...") + shutil.copy(template_file, env_file) + print("\n[Action Required] Please open '.env' and fill in your Wi-Fi credentials.") + sys.exit(0) + else: + print("[Error] Neither '.env' nor '.env.template' could be found.") + sys.exit(1) + + load_dotenv(env_file) + + # 1. Select YAML Configuration + yaml_files = sorted([f.name for f in script_dir.glob("*.yaml")]) + if not yaml_files: + print("[Error] No ESPHome configuration files (*.yaml) found in this directory.") + sys.exit(1) + + selected_yaml = questionary.select( + "Select the ESPHome configuration to compile/flash:", + choices=yaml_files + ).ask() + + if not selected_yaml: + sys.exit(0) + + # 2. Select Command Action + commands = [ + questionary.Choice("Run (Validate, compile, upload, and start logs)", "run"), + questionary.Choice("Compile (Build only)", "compile"), + questionary.Choice("Upload (Flash compiled binary only)", "upload"), + questionary.Choice("Logs (Stream device logs)", "logs"), + questionary.Choice("Validate (Check syntax only)", "config"), + questionary.Choice("Clean (Clear build artifacts)", "clean"), + ] + + selected_cmd = questionary.select( + "Select ESPHome action to perform:", + choices=commands + ).ask() + + if not selected_cmd: + sys.exit(0) + + # If upload is selected, verify build files exist + if selected_cmd == "upload": + device_name = get_device_name(script_dir / selected_yaml) + if device_name: + build_dir = script_dir / ".esphome" / "build" / device_name + if not build_dir.exists(): + print(f"\n[Warning] Build directory for '{device_name}' does not exist.") + print("You must compile the project before uploading it.") + confirm = questionary.confirm( + "Would you like to compile + upload the configuration now (Run)?", + default=True + ).ask() + if confirm: + selected_cmd = "run" + else: + sys.exit(0) + + # Prepare system environment + env = os.environ.copy() + + # Execute ESPHome command + exit_code = run_esphome([selected_cmd, selected_yaml], env) + sys.exit(exit_code) + +if __name__ == "__main__": + main() diff --git a/esphome/prometheus_client.h b/esphome/prometheus_client.h new file mode 100644 index 0000000..0870d61 --- /dev/null +++ b/esphome/prometheus_client.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include "esphome.h" + +// Forward declare the global variables/pointers defined in main.cpp +extern template_::TemplateSensor *local_cellar_temp; +extern template_::TemplateSensor *local_cellar_humi; +extern template_::TemplateSensor *local_cellar_dp; +extern template_::TemplateSensor *local_cellar_out_temp; +extern template_::TemplateSensor *local_cellar_out_humi; +extern template_::TemplateSensor *local_cellar_out_dp; +extern template_::TemplateSensor *local_fan_status; +extern template_::TemplateSensor *local_mold_critical; +extern template_::TemplateSensor *local_fan_would_activate; +extern template_::TemplateSensor *local_mold_threshold; +extern template_::TemplateSensor *local_shelly_online; +extern globals::GlobalsComponent *prometheus_connected; + +inline void fetch_prometheus() { + WiFiClient client; + if (!client.connect("prometheus.haumdaucher.de", 80)) { + prometheus_connected->value() = false; + ESP_LOGW("taupi", "Failed to connect to Prometheus over HTTP"); + return; + } + + // Send HTTP/1.0 request to avoid chunked transfer encoding! + client.println("GET /api/v1/query?query=%7B__name__%3D~%22taupi_.*%7Cup%22%2Cjob%3D%22taupi-fan%22%7D HTTP/1.0"); + client.println("Host: prometheus.haumdaucher.de"); + client.println("Authorization: Basic bW9yaXR6OlZhZWo2UXVpZXF1NHZvMGphZVJh"); + client.println("Connection: close"); + client.println(); + + // Read headers + bool headers_ended = false; + std::string body = ""; + while (client.connected() || client.available()) { + if (client.available()) { + if (!headers_ended) { + String line = client.readStringUntil('\n'); + if (line == "\r" || line == "") { + headers_ended = true; + } + } else { + String data = client.readString(); + body += data.c_str(); + } + } + } + client.stop(); + + if (body.length() == 0) { + prometheus_connected->value() = false; + ESP_LOGW("taupi", "Empty response body from Prometheus"); + return; + } + + prometheus_connected->value() = true; + + // Parse body using ArduinoJson 7 + JsonDocument doc; + DeserializationError error = deserializeJson(doc, body); + if (!error) { + JsonObject root = doc.as(); + JsonObject data = root["data"]; + JsonArray result = data["result"]; + for (JsonObject item : result) { + JsonObject metric = item["metric"]; + std::string name = metric["__name__"].as(); + JsonArray value = item["value"]; + if (value.size() >= 2) { + std::string val_str = value[1].as(); + float val = atof(val_str.c_str()); + + if (name == "taupi_temperature_celsius_innen") local_cellar_temp->publish_state(val); + else if (name == "taupi_humidity_percent_innen") local_cellar_humi->publish_state(val); + else if (name == "taupi_dewpoint_celsius_innen") local_cellar_dp->publish_state(val); + else if (name == "taupi_temperature_celsius_aussen") local_cellar_out_temp->publish_state(val); + else if (name == "taupi_humidity_percent_aussen") local_cellar_out_humi->publish_state(val); + else if (name == "taupi_dewpoint_celsius_aussen") local_cellar_out_dp->publish_state(val); + else if (name == "taupi_relay_status") local_fan_status->publish_state(val); + else if (name == "taupi_is_critical") local_mold_critical->publish_state(val); + else if (name == "taupi_would_fan_activate") local_fan_would_activate->publish_state(val); + else if (name == "taupi_critical_humidity_threshold_percent") local_mold_threshold->publish_state(val); + else if (name == "up") { + std::string job = metric["job"].as(); + if (job == "taupi-fan") { + local_shelly_online->publish_state(val); + } + } + } + } + ESP_LOGD("taupi", "Successfully parsed Taupi metrics from Prometheus"); + } else { + ESP_LOGW("taupi", "JSON parsing failed: %s", error.c_str()); + } +} diff --git a/esphome/send_message.sh b/esphome/send_message.sh new file mode 100755 index 0000000..4613c14 --- /dev/null +++ b/esphome/send_message.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Exit on error +set -e + +CDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Read the token from the home-assistant secrets folder +TOKEN_FILE="${CDIR}/../k8s/home-assistant/tmp_long_lived_token.secret.yml" + +if [ ! -f "$TOKEN_FILE" ]; then + echo "Error: Home Assistant token file not found at $TOKEN_FILE." + exit 1 +fi + +# Extract the token (grab the line that is not a comment) +TOKEN=$(grep -v '^#' "$TOKEN_FILE" | tr -d '\n' | tr -d '\r' | xargs) + +if [ -z "$TOKEN" ]; then + echo "Error: Token extracted from $TOKEN_FILE is empty." + exit 1 +fi + +# Get the message from arguments +MESSAGE="${1:-"Hello from command line!"}" + +echo "Sending message: '$MESSAGE' to Home Assistant (https://hass.moritzgraf.de)..." + +# Call the Home Assistant REST API to set the value of the input_text helper +curl -s -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"entity_id\": \"input_text.epaper_message\", \"value\": \"$MESSAGE\"}" \ + https://hass.moritzgraf.de/api/services/input_text/set_value + +echo -e "\nDone!"