Adding esphome display

This commit is contained in:
Moritz Graf 2026-07-07 21:01:46 +02:00
parent 1501d7562e
commit 26a68a8962
8 changed files with 620 additions and 0 deletions

3
esphome/.env.template Normal file
View File

@ -0,0 +1,3 @@
# Wi-Fi credentials for ESPHome devices
WIFI_SSID="your_wifi_ssid"
WIFI_PASSWORD="your_wifi_password"

2
esphome/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
.esphome/

20
esphome/AGENTS.md Normal file
View File

@ -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_<name>.yaml` or `sensor_<name>.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.

48
esphome/README.md Normal file
View File

@ -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.

View File

@ -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);

142
esphome/flash.py Executable file
View File

@ -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()

View File

@ -0,0 +1,99 @@
#pragma once
#include <WiFiClient.h>
#include <ArduinoJson.h>
#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<bool> *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>();
JsonObject data = root["data"];
JsonArray result = data["result"];
for (JsonObject item : result) {
JsonObject metric = item["metric"];
std::string name = metric["__name__"].as<std::string>();
JsonArray value = item["value"];
if (value.size() >= 2) {
std::string val_str = value[1].as<std::string>();
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<std::string>();
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());
}
}

35
esphome/send_message.sh Executable file
View File

@ -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!"