So, this is how it works for me now:
globals:
- id: forecast_daily_count
type: int
initial_value: '0'
- id: forecast_daily_timestamps
type: time_t[10]
- id: forecast_daily_temps
type: float[10]
- id: forecast_daily_conditions
type: std::string[10]
- id: forecast_hourly_count
type: int
initial_value: '0'
- id: forecast_hourly_timestamps
type: time_t[15]
- id: forecast_hourly_temps
type: float[15]
- id: forecast_hourly_conditions
type: std::string[15]
sensor:
# Weather forecast daily count
- platform: homeassistant
id: weather_forecast_daily_count
entity_id: sensor.display_tools_forecasts_daily
internal: true
# Weather forecast hourly count
- platform: homeassistant
id: weather_forecast_hourly_count
entity_id: sensor.display_tools_forecasts_hourly
internal: true
text_sensor:
# Weather forecast daily sensor
- platform: homeassistant
id: weather_forecast_daily_sensor
entity_id: sensor.display_tools_forecasts_daily
attribute: forecasts
internal: true
on_value:
then:
- script.execute: parse_daily_forecasts
# Weather forecast hourly sensor
- platform: homeassistant
id: weather_forecast_hourly_sensor
entity_id: sensor.display_tools_forecasts_hourly
attribute: forecasts
internal: true
on_value:
then:
- script.execute: parse_hourly_forecasts
script:
- id: parse_daily_forecasts
then:
- lambda: |-
ESP_LOGI("weather", "=== Parsing DAILY Forecasts ===");
std::string json_str = id(weather_forecast_daily_sensor).state;
if (json_str.empty() || json_str == "unknown") {
ESP_LOGW("weather", "No daily forecast data");
id(forecast_daily_count) = 0;
return;
}
ESP_LOGI("weather", "Daily JSON: %d bytes", json_str.length());
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json_str);
if (error) {
ESP_LOGE("weather", "Parse error: %s", error.c_str());
id(forecast_daily_count) = 0;
return;
}
JsonArray forecasts = doc.as<JsonArray>();
if (forecasts.isNull()) {
ESP_LOGE("weather", "Not an array!");
id(forecast_daily_count) = 0;
return;
}
int count = std::min((int)forecasts.size(), 6);
id(forecast_daily_count) = count;
ESP_LOGI("weather", "Processing %d daily forecasts", count);
auto local_now = id(sntp_time).now();
auto utc_now = id(sntp_time).utcnow();
int32_t offset_hours = local_now.hour - utc_now.hour;
if (offset_hours > 12) offset_hours -= 24;
if (offset_hours < -12) offset_hours += 24;
int32_t tz_offset = offset_hours * 3600 +
(local_now.minute - utc_now.minute) * 60 +
(local_now.second - utc_now.second);
ESP_LOGI("parse_debug", "Detected TZ offset: %+d seconds (%+d hours)",
tz_offset, offset_hours);
for (int i = 0; i < count; i++) {
JsonObject forecast = forecasts[i];
const char* dt_str = forecast["datetime"];
if (dt_str) {
ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
ESP_LOGI("parse_debug", "📅 Raw datetime: %s", dt_str);
struct tm tm = {};
if (strptime(dt_str, "%Y-%m-%dT%H:%M:%S", &tm)) {
time_t local_ts = mktime(&tm);
time_t utc_timestamp = local_ts + tz_offset;
id(forecast_daily_timestamps)[i] = utc_timestamp;
ESP_LOGI("parse_debug", "🕐 Parsed time: %04d-%02d-%02d %02d:%02d:%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
ESP_LOGI("parse_debug", " mktime result (as MSK): %ld", local_ts);
ESP_LOGI("parse_debug", "✅ UTC Timestamp: %ld (offset: %+d)",
utc_timestamp, tz_offset);
struct tm check_utc;
gmtime_r(&utc_timestamp, &check_utc);
ESP_LOGI("parse_debug", " Check UTC: %04d-%02d-%02d %02d:%02d:%02d (wday: %d)",
check_utc.tm_year + 1900, check_utc.tm_mon + 1, check_utc.tm_mday,
check_utc.tm_hour, check_utc.tm_min, check_utc.tm_sec,
check_utc.tm_wday);
time_t local_display = utc_timestamp + tz_offset;
struct tm check_local;
gmtime_r(&local_display, &check_local);
ESP_LOGI("parse_debug", " Check Local: %04d-%02d-%02d %02d:%02d:%02d (wday: %d)",
check_local.tm_year + 1900, check_local.tm_mon + 1, check_local.tm_mday,
check_local.tm_hour, check_local.tm_min, check_local.tm_sec,
check_local.tm_wday);
ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
} else {
ESP_LOGE("parse_debug", "❌ Failed to parse: %s", dt_str);
id(forecast_daily_timestamps)[i] = 0;
}
} else {
ESP_LOGW("parse_debug", "⚠️ No datetime for forecast %d", i);
id(forecast_daily_timestamps)[i] = 0;
}
id(forecast_daily_temps)[i] = forecast["temperature"] | 0.0f;
id(forecast_daily_conditions)[i] = forecast["condition"] | "sunny";
ESP_LOGD("weather", "[%d] %.1f°C, %s",
i, id(forecast_daily_temps)[i],
id(forecast_daily_conditions)[i].c_str());
}
ESP_LOGI("weather", "✅ Daily parsing complete");
- script.execute: update_daily_display
- id: parse_hourly_forecasts
then:
- lambda: |-
ESP_LOGI("weather", "=== Parsing HOURLY Forecasts ===");
std::string json_str = id(weather_forecast_hourly_sensor).state;
if (json_str.empty() || json_str == "unknown") {
ESP_LOGW("weather", "No hourly forecast data");
id(forecast_hourly_count) = 0;
return;
}
ESP_LOGI("weather", "Hourly JSON: %d bytes", json_str.length());
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json_str);
if (error) {
ESP_LOGE("weather", "Parse error: %s", error.c_str());
id(forecast_hourly_count) = 0;
return;
}
JsonArray forecasts = doc.as<JsonArray>();
if (forecasts.isNull()) {
ESP_LOGE("weather", "Not an array!");
id(forecast_hourly_count) = 0;
return;
}
int count = forecasts.size();
id(forecast_hourly_count) = count;
ESP_LOGI("weather", "Processing %d hourly forecasts", count);
auto local_now = id(sntp_time).now();
auto utc_now = id(sntp_time).utcnow();
int32_t offset_hours = local_now.hour - utc_now.hour;
if (offset_hours > 12) offset_hours -= 24;
if (offset_hours < -12) offset_hours += 24;
int32_t tz_offset = offset_hours * 3600 +
(local_now.minute - utc_now.minute) * 60 +
(local_now.second - utc_now.second);
ESP_LOGI("parse_debug", "Detected TZ offset: %+d seconds (%+d hours)",
tz_offset, offset_hours);
for (int i = 0; i < count; i++) {
JsonObject forecast = forecasts[i];
const char* dt_str = forecast["datetime"];
if (dt_str) {
ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
ESP_LOGI("parse_debug", "📅 Raw datetime: %s", dt_str);
struct tm tm = {};
if (strptime(dt_str, "%Y-%m-%dT%H:%M:%S", &tm)) {
time_t local_ts = mktime(&tm);
time_t utc_timestamp = local_ts + tz_offset;
id(forecast_hourly_timestamps)[i] = utc_timestamp;
ESP_LOGI("parse_debug", "🕐 Parsed time: %04d-%02d-%02d %02d:%02d:%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
ESP_LOGI("parse_debug", " mktime result (as MSK): %ld", local_ts);
ESP_LOGI("parse_debug", "✅ UTC Timestamp: %ld (offset: %+d)",
utc_timestamp, tz_offset);
struct tm check_utc;
gmtime_r(&utc_timestamp, &check_utc);
ESP_LOGI("parse_debug", " Check UTC: %04d-%02d-%02d %02d:%02d:%02d",
check_utc.tm_year + 1900, check_utc.tm_mon + 1, check_utc.tm_mday,
check_utc.tm_hour, check_utc.tm_min, check_utc.tm_sec);
time_t local_display = utc_timestamp + tz_offset;
struct tm check_local;
gmtime_r(&local_display, &check_local);
ESP_LOGI("parse_debug", " Check Local: %04d-%02d-%02d %02d:%02d:%02d",
check_local.tm_year + 1900, check_local.tm_mon + 1, check_local.tm_mday,
check_local.tm_hour, check_local.tm_min, check_local.tm_sec);
ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
} else {
ESP_LOGE("parse_debug", "❌ Failed to parse: %s", dt_str);
id(forecast_hourly_timestamps)[i] = 0;
}
} else {
ESP_LOGW("parse_debug", "⚠️ No datetime for forecast %d", i);
id(forecast_hourly_timestamps)[i] = 0;
}
id(forecast_hourly_temps)[i] = forecast["temperature"] | 0.0f;
id(forecast_hourly_conditions)[i] = forecast["condition"] | "sunny";
ESP_LOGD("weather", "[%d] %s: %.1f°C, %s",
i, dt_str ? dt_str : "unknown",
id(forecast_hourly_temps)[i],
id(forecast_hourly_conditions)[i].c_str());
}
ESP_LOGI("weather", "✅ Hourly parsing complete");
- script.execute: update_hourly_display
- id: update_daily_display
then:
- lambda: |-
ESP_LOGI("weather", "=== Updating DAILY Display ===");
if (id(forecast_daily_count) == 0) return;
if (!id(weather_forecast_daily)) return;
static esphome::lvgl::FontEngine fe_nunito_16(id(nunito_16));
lv_obj_clean(id(weather_forecast_daily));
auto get_icon = [](const std::string& c) -> esphome::image::Image* {
if (c == "clear-night") return id(clear_night_small_img);
if (c == "cloudy") return id(cloudy_small_img);
if (c == "fog") return id(fog_small_img);
if (c == "hail") return id(hail_small_img);
if (c == "lightning") return id(lightning_small_img);
if (c == "lightning-rainy") return id(lightning_rainy_small_img);
if (c == "partlycloudy") return id(partlycloudy_sun_small_img);
if (c == "pouring") return id(pouring_small_img);
if (c == "rainy") return id(rainy_small_img);
if (c == "snowy") return id(snowy_small_img);
if (c == "snowy-rainy") return id(snowy_rainy_small_img);
if (c == "sunny") return id(sunny_small_img);
if (c == "windy") return id(windy_small_img);
if (c == "windy-variant") return id(windy_variant_small_img);
return id(sunny_small_img);
};
auto get_day_name = [](int index) -> const char* {
auto time_now = id(sntp_time).now();
int today_wday = time_now.day_of_week;
int forecast_wday = (today_wday + index - 1) % 7 + 1;
const char* day_keys[] = {
"", "day_of_week_short.sunday", "day_of_week_short.monday",
"day_of_week_short.tuesday", "day_of_week_short.wednesday",
"day_of_week_short.thursday", "day_of_week_short.friday",
"day_of_week_short.saturday"
};
if (forecast_wday < 1 || forecast_wday > 7) return "???";
static std::string result;
result = id(i18n_translations).translate(day_keys[forecast_wday]);
return result.c_str();
};
lv_obj_t* cont = lv_obj_create(id(weather_forecast_daily));
if (!cont) return;
lv_obj_set_size(cont, 408, 150);
lv_obj_align(cont, LV_ALIGN_CENTER, 0, 5);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
lv_obj_set_style_pad_all(cont, 0, 0);
// lv_obj_set_style_pad_row(cont, 0, 0);
lv_obj_set_style_pad_column(cont, 0, 0);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW);
// lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (int i = 0; i < id(forecast_daily_count); i++) {
lv_obj_t* item = lv_obj_create(cont);
if (!item) continue;
lv_obj_set_size(item, 68, 120);
lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(item, 0, 0);
lv_obj_set_style_pad_all(item, 0, 0);
lv_obj_t* day_label = lv_label_create(item);
if (day_label) {
lv_label_set_text(day_label, get_day_name(i));
lv_obj_align(day_label, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_text_font(day_label, fe_nunito_16.get_lv_font(), 0);
lv_obj_set_style_text_color(day_label, lv_color_hex(0x9BA2BC), 0);
}
lv_obj_t* icon = lv_img_create(item);
if (icon) {
lv_img_set_src(icon, get_icon(id(forecast_daily_conditions)[i]));
lv_obj_align(icon, LV_ALIGN_CENTER, 0, 0);
}
lv_obj_t* temp_label = lv_label_create(item);
if (temp_label) {
char temp_str[10];
snprintf(temp_str, sizeof(temp_str), "%.0f°", id(forecast_daily_temps)[i]);
lv_label_set_text(temp_label, temp_str);
lv_obj_align(temp_label, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_text_font(temp_label, fe_nunito_16.get_lv_font(), 0);
lv_obj_set_style_text_color(temp_label, lv_color_hex(0x9BA2BC), 0);
}
}
ESP_LOGI("weather", "✅ Created %d daily items", id(forecast_daily_count));
- id: update_hourly_display
then:
- lambda: |-
ESP_LOGI("weather", "=== Updating HOURLY Display ===");
if (id(forecast_hourly_count) == 0) return;
if (!id(weather_forecast_hourly)) return;
static esphome::lvgl::FontEngine fe_nunito_16(id(nunito_16));
lv_obj_clean(id(weather_forecast_hourly));
auto get_icon = [](const std::string& c) -> esphome::image::Image* {
if (c == "clear-night") return id(clear_night_small_img);
if (c == "cloudy") return id(cloudy_small_img);
if (c == "fog") return id(fog_small_img);
if (c == "hail") return id(hail_small_img);
if (c == "lightning") return id(lightning_small_img);
if (c == "lightning-rainy") return id(lightning_rainy_small_img);
if (c == "partlycloudy") return id(partlycloudy_sun_small_img);
if (c == "pouring") return id(pouring_small_img);
if (c == "rainy") return id(rainy_small_img);
if (c == "snowy") return id(snowy_small_img);
if (c == "snowy-rainy") return id(snowy_rainy_small_img);
if (c == "sunny") return id(sunny_small_img);
if (c == "windy") return id(windy_small_img);
if (c == "windy-variant") return id(windy_variant_small_img);
return id(sunny_small_img);
};
auto get_time_str = [](time_t utc_timestamp) -> std::string {
if (utc_timestamp == 0) return "--:--";
auto local_now = id(sntp_time).now();
auto utc_now = id(sntp_time).utcnow();
int32_t offset_hours = local_now.hour - utc_now.hour;
if (offset_hours > 12) offset_hours -= 24;
if (offset_hours < -12) offset_hours += 24;
int32_t tz_offset = offset_hours * 3600 +
(local_now.minute - utc_now.minute) * 60 +
(local_now.second - utc_now.second);
time_t local_timestamp = utc_timestamp + tz_offset;
struct tm local_tm;
gmtime_r(&local_timestamp, &local_tm);
char buf[10];
snprintf(buf, sizeof(buf), "%02d:%02d", local_tm.tm_hour, local_tm.tm_min);
return std::string(buf);
};
lv_obj_t* cont = lv_obj_create(id(weather_forecast_hourly));
if (!cont) return;
lv_obj_set_size(cont, LV_SIZE_CONTENT, 150);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
lv_obj_set_style_pad_all(cont, 0, 0);
lv_obj_set_style_pad_column(cont, 0, 0);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW);
lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_AUTO);
lv_obj_set_scroll_dir(cont, LV_DIR_HOR);
for (int i = 0; i < id(forecast_hourly_count); i++) {
lv_obj_t* item = lv_obj_create(cont);
if (!item) continue;
lv_obj_set_size(item, 68, 120);
lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(item, 0, 0);
lv_obj_set_style_pad_all(item, 2, 0);
lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* time_label = lv_label_create(item);
if (time_label) {
std::string time_str = get_time_str(id(forecast_hourly_timestamps)[i]);
lv_label_set_text(time_label, time_str.c_str());
lv_obj_align(time_label, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_text_font(time_label, fe_nunito_16.get_lv_font(), 0);
lv_obj_set_style_text_color(time_label, lv_color_hex(0x9BA2BC), 0);
}
lv_obj_t* icon = lv_img_create(item);
if (icon) {
lv_img_set_src(icon, get_icon(id(forecast_hourly_conditions)[i]));
lv_obj_align(icon, LV_ALIGN_CENTER, 0, 0);
}
lv_obj_t* temp_label = lv_label_create(item);
if (temp_label) {
char temp_str[10];
snprintf(temp_str, sizeof(temp_str), "%.0f°", id(forecast_hourly_temps)[i]);
lv_label_set_text(temp_label, temp_str);
lv_obj_align(temp_label, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_text_font(temp_label, fe_nunito_16.get_lv_font(), 0);
lv_obj_set_style_text_color(temp_label, lv_color_hex(0x9BA2BC), 0);
}
}
ESP_LOGI("weather", "✅ Created %d hourly items with scroll", id(forecast_hourly_count));
lvgl:
# example
- obj:
id: weather_forecast_daily
hidden: false
width: 408
height: 160
align: bottom_mid
clickable: false
radius: 20
pad_all: 0
bg_opa: transp
border_opa: transp
border_width: 0
shadow_opa: transp
layout:
type: flex
flex_flow: column
flex_align_main: center
flex_align_cross: center
- obj:
id: weather_forecast_hourly
hidden: true
width: 408
height: 160
align: bottom_mid
clickable: false
radius: 20
pad_all: 0
bg_opa: transp
border_opa: transp
border_width: 0
shadow_opa: transp
layout:
type: flex
flex_flow: column
flex_align_main: center
flex_align_cross: center
# example
- button:
id: weather_widget_btn
clickable: true
x: -110
width: 50
height: 50
align: right_mid
bg_opa: transp
shadow_opa: transp
widgets:
- label:
align: center
text_color: color_misty_blue
text_font: mdi_icons_28
text: "${weather_icon}"
on_click:
- homeassistant.action:
action: display_tools.get_forecasts
data:
entity_id: "${weather_entity}"
type: "daily"
- homeassistant.action:
action: display_tools.get_forecasts
data:
entity_id: "${weather_entity}"
type: "hourly"
Essentially, the difficulty is that the sensor data is an array, which still needs to be parsed for datetime and timezone. Plus, the scripts create dynamic widgets using native LVGL. But oddly enough, this is probably the simplest way to work with forecasts.