mirror of
https://github.com/randybb/esphome-configs.git
synced 2026-10-14 08:35:12 +02:00
Compare commits
6
Commits
29b375df68
...
7ee975875a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee975875a | ||
|
|
dd508aec99 | ||
|
|
e13be73596 | ||
|
|
b523963944 | ||
|
|
6ca813fb64 | ||
|
|
d80abdbb90 |
No files matched your search
@@ -10,7 +10,7 @@ external_components:
|
|||||||
# audio HTTP reader over IPv6 (dual-stack hosts resolve A first), drop once in #19750
|
# audio HTTP reader over IPv6 (dual-stack hosts resolve A first), drop once in #19750
|
||||||
- source: github://randybb/esphome-components
|
- source: github://randybb/esphome-components
|
||||||
components: [audio]
|
components: [audio]
|
||||||
refresh: 1d
|
refresh: 1h
|
||||||
|
|
||||||
wifi:
|
wifi:
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#pragma once
|
||||||
|
// Upcoming calendar events for mcu-m5paper: parsed from the calendar.get_events
|
||||||
|
// response and kept in flash (NVS), so they survive the power-off between RTC wake-ups.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "esphome/components/json/json_util.h"
|
||||||
|
#include "esphome/core/preferences.h"
|
||||||
|
#include "esphome/core/time.h"
|
||||||
|
|
||||||
|
namespace m5paper_calendar {
|
||||||
|
|
||||||
|
static constexpr uint8_t MAX_EVENTS = 32;
|
||||||
|
static constexpr size_t SUMMARY_LEN = 44;
|
||||||
|
static constexpr uint16_t ALL_DAY = 0xFFFF;
|
||||||
|
static constexpr uint32_t PREF_HASH = 0x6D35C41A;
|
||||||
|
|
||||||
|
enum Kind : uint8_t { BIRTHDAY = 0, WASTE };
|
||||||
|
|
||||||
|
struct Event {
|
||||||
|
uint16_t day; // local date, days since 1970-01-01
|
||||||
|
uint16_t minute; // local start time in minutes after midnight, ALL_DAY if none
|
||||||
|
uint8_t kind;
|
||||||
|
char summary[SUMMARY_LEN + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Events {
|
||||||
|
uint32_t fetched_at{0}; // UTC epoch of the download
|
||||||
|
uint8_t count{0};
|
||||||
|
Event items[MAX_EVENTS]{};
|
||||||
|
|
||||||
|
bool valid() const { return this->fetched_at != 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// the events in RAM, loaded from flash at boot
|
||||||
|
inline Events &events() {
|
||||||
|
static Events ev;
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm)
|
||||||
|
inline int32_t days_from_civil(int y, int m, int d) {
|
||||||
|
y -= m <= 2;
|
||||||
|
const int era = (y >= 0 ? y : y - 399) / 400;
|
||||||
|
const int yoe = y - era * 400;
|
||||||
|
const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||||
|
const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
return era * 146097 + doe - 719468;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int32_t local_day(const esphome::ESPTime &t) { return days_from_civil(t.year, t.month, t.day_of_month); }
|
||||||
|
|
||||||
|
// 0 = Monday
|
||||||
|
inline int weekday(int32_t day) { return (day + 3) % 7; }
|
||||||
|
|
||||||
|
// "2026-10-01" (all-day) or "2026-10-01T10:00:00+02:00" -> local day and minute; false on error
|
||||||
|
inline bool parse_start(const char *s, uint16_t &day, uint16_t &minute) {
|
||||||
|
int y, mo, d, h, mi, sec = 0, oh = 0, om = 0;
|
||||||
|
char sign = '+';
|
||||||
|
int n = sscanf(s, "%d-%d-%dT%d:%d:%d%c%d:%d", &y, &mo, &d, &h, &mi, &sec, &sign, &oh, &om);
|
||||||
|
if (n < 3)
|
||||||
|
return false;
|
||||||
|
if (n < 5) {
|
||||||
|
day = days_from_civil(y, mo, d);
|
||||||
|
minute = ALL_DAY;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int64_t ts = int64_t(days_from_civil(y, mo, d)) * 86400 + h * 3600 + mi * 60 + sec;
|
||||||
|
if (n >= 8 && (sign == '+' || sign == '-')) {
|
||||||
|
int64_t offset = oh * 3600 + om * 60;
|
||||||
|
ts += sign == '+' ? -offset : offset;
|
||||||
|
}
|
||||||
|
if (ts <= 0)
|
||||||
|
return false;
|
||||||
|
const auto t = esphome::ESPTime::from_epoch_local(ts);
|
||||||
|
day = local_day(t);
|
||||||
|
minute = t.hour * 60 + t.minute;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy at most SUMMARY_LEN bytes without cutting a UTF-8 sequence
|
||||||
|
inline void copy_summary(char *dst, const char *src) {
|
||||||
|
size_t n = strnlen(src, SUMMARY_LEN + 1);
|
||||||
|
if (n > SUMMARY_LEN) {
|
||||||
|
n = SUMMARY_LEN;
|
||||||
|
while (n > 0 && (uint8_t(src[n]) & 0xC0) == 0x80)
|
||||||
|
n--;
|
||||||
|
}
|
||||||
|
memcpy(dst, src, n);
|
||||||
|
dst[n] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the calendar.get_events response; Home Assistant wraps the action response as
|
||||||
|
// {"response": {"calendar.x": {"events": [...]}, ...}}; every calendar other than the waste one holds birthdays
|
||||||
|
inline bool parse(ArduinoJson::JsonObjectConst response, const char *waste, uint32_t now, Events &ev) {
|
||||||
|
ArduinoJson::JsonObjectConst root = response["response"].is<ArduinoJson::JsonObjectConst>()
|
||||||
|
? response["response"].as<ArduinoJson::JsonObjectConst>()
|
||||||
|
: response;
|
||||||
|
if (root.size() == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Events out;
|
||||||
|
for (ArduinoJson::JsonPairConst cal : root) {
|
||||||
|
const uint8_t kind = strcmp(cal.key().c_str(), waste) == 0 ? WASTE : BIRTHDAY;
|
||||||
|
for (ArduinoJson::JsonObjectConst item : cal.value()["events"].as<ArduinoJson::JsonArrayConst>()) {
|
||||||
|
Event e;
|
||||||
|
if (!parse_start(item["start"] | "", e.day, e.minute))
|
||||||
|
continue;
|
||||||
|
e.kind = kind;
|
||||||
|
copy_summary(e.summary, item["summary"] | "");
|
||||||
|
// the same birthday in the contacts and the manual calendar
|
||||||
|
if (std::any_of(out.items, out.items + out.count,
|
||||||
|
[&e](const Event &o) { return o.day == e.day && strcmp(o.summary, e.summary) == 0; }))
|
||||||
|
continue;
|
||||||
|
if (out.count < MAX_EVENTS) {
|
||||||
|
out.items[out.count++] = e;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// full: keep the nearest events
|
||||||
|
Event *last = std::max_element(out.items, out.items + out.count,
|
||||||
|
[](const Event &a, const Event &b) { return a.day < b.day; });
|
||||||
|
if (e.day < last->day)
|
||||||
|
*last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(out.items, out.items + out.count, [](const Event &a, const Event &b) {
|
||||||
|
// all-day (0xFFFF wraps to 0) first
|
||||||
|
return a.day != b.day ? a.day < b.day : uint16_t(a.minute + 1) < uint16_t(b.minute + 1);
|
||||||
|
});
|
||||||
|
out.fetched_at = now;
|
||||||
|
ev = out;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// battery cycle: refreshed once a day, after midnight
|
||||||
|
inline bool stale(const esphome::ESPTime &now) {
|
||||||
|
const auto &ev = events();
|
||||||
|
return !ev.valid() || !now.is_valid() || now.timestamp - ev.fetched_at >= 20 * 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline esphome::ESPPreferenceObject &pref() {
|
||||||
|
static esphome::ESPPreferenceObject p = esphome::global_preferences->make_preference<Events>(PREF_HASH, true);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool load(Events &ev) {
|
||||||
|
if (!pref().load(&ev)) {
|
||||||
|
ev = Events{};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ev.count = std::min(ev.count, MAX_EVENTS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// written to flash right away: the board may power off before the next preferences flush
|
||||||
|
inline bool save(const Events &ev) { return pref().save(&ev) && esphome::global_preferences->sync(); }
|
||||||
|
|
||||||
|
} // namespace m5paper_calendar
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Hourly weather forecast for mcu-m5paper: parsed from the weather.get_forecasts
|
|
||||||
// response and kept in the on-board FM24C02 EEPROM (256 B, I2C 0x50), so it
|
|
||||||
// survives the power-off between RTC wake-ups.
|
|
||||||
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "esphome/components/i2c/i2c_bus.h"
|
|
||||||
#include "esphome/components/json/json_util.h"
|
|
||||||
#include "esphome/core/hal.h"
|
|
||||||
|
|
||||||
namespace m5paper_weather {
|
|
||||||
|
|
||||||
static constexpr uint8_t EEPROM_ADDR = 0x50;
|
|
||||||
static constexpr uint8_t EEPROM_PAGE = 8;
|
|
||||||
static constexpr uint8_t MAGIC = 0xA5;
|
|
||||||
static constexpr uint8_t VERSION = 1;
|
|
||||||
static constexpr uint8_t MAX_HOURS = 48;
|
|
||||||
// magic, version, fetched_at (u32), first_ts (u32), count
|
|
||||||
static constexpr size_t HEADER = 11;
|
|
||||||
static constexpr size_t MAX_SIZE = HEADER + MAX_HOURS * 4;
|
|
||||||
static_assert(MAX_SIZE <= 256, "forecast does not fit into the EEPROM");
|
|
||||||
|
|
||||||
static constexpr uint8_t NO_VALUE = 0xFF;
|
|
||||||
static constexpr int8_t NO_TEMP = INT8_MIN;
|
|
||||||
|
|
||||||
struct Hour {
|
|
||||||
uint8_t cond; // index into CONDITIONS, NO_VALUE if unknown
|
|
||||||
int8_t temp2; // temperature in 0.5 °C, NO_TEMP if unknown
|
|
||||||
uint8_t wind; // km/h, NO_VALUE if unknown
|
|
||||||
uint8_t pop; // precipitation probability %, NO_VALUE if unknown
|
|
||||||
};
|
|
||||||
static_assert(sizeof(Hour) == 4, "Hour must stay 4 bytes");
|
|
||||||
|
|
||||||
struct Forecast {
|
|
||||||
uint32_t fetched_at{0}; // UTC epoch of the download
|
|
||||||
uint32_t first_ts{0}; // UTC epoch of hours[0]; entries are 1 h apart
|
|
||||||
uint8_t count{0};
|
|
||||||
Hour hours[MAX_HOURS]{};
|
|
||||||
|
|
||||||
bool valid() const { return this->count > 0; }
|
|
||||||
// hour covering the given UTC epoch, nullptr if outside the forecast
|
|
||||||
const Hour *at(uint32_t ts) const {
|
|
||||||
if (!this->valid() || ts < this->first_ts)
|
|
||||||
return nullptr;
|
|
||||||
uint32_t idx = (ts - this->first_ts) / 3600;
|
|
||||||
return idx < this->count ? &this->hours[idx] : nullptr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// the forecast in RAM, loaded from the EEPROM at boot
|
|
||||||
inline Forecast &forecast() {
|
|
||||||
static Forecast fc;
|
|
||||||
return fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Home Assistant weather conditions; the index is the stored condition code
|
|
||||||
static const char *const CONDITIONS[] = {
|
|
||||||
"clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy",
|
|
||||||
"pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant",
|
|
||||||
};
|
|
||||||
static const char *const CONDITION_NAMES[] = {
|
|
||||||
"Jasno", "Oblačno", "Extrémne počasie", "Hmla", "Krupobitie", "Búrka", "Búrka s dažďom", "Polooblačno",
|
|
||||||
"Lejak", "Dážď", "Sneženie", "Dážď so snehom", "Slnečno", "Veterno", "Veterno, oblačno",
|
|
||||||
};
|
|
||||||
static constexpr uint8_t NUM_CONDITIONS = sizeof(CONDITIONS) / sizeof(CONDITIONS[0]);
|
|
||||||
|
|
||||||
inline uint8_t condition_code(const char *name) {
|
|
||||||
for (uint8_t i = 0; i < NUM_CONDITIONS; i++) {
|
|
||||||
if (strcmp(name, CONDITIONS[i]) == 0)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
return NO_VALUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline const char *condition_name(uint8_t code) { return code < NUM_CONDITIONS ? CONDITION_NAMES[code] : "?"; }
|
|
||||||
|
|
||||||
// days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm)
|
|
||||||
inline int32_t days_from_civil(int y, int m, int d) {
|
|
||||||
y -= m <= 2;
|
|
||||||
const int era = (y >= 0 ? y : y - 399) / 400;
|
|
||||||
const int yoe = y - era * 400;
|
|
||||||
const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
|
||||||
const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
||||||
return era * 146097 + doe - 719468;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "2026-09-27T16:00:00+00:00" -> UTC epoch, 0 on error
|
|
||||||
inline uint32_t parse_iso8601(const char *s) {
|
|
||||||
int y, mo, d, h, mi, sec = 0, oh = 0, om = 0;
|
|
||||||
char sign = '+';
|
|
||||||
int n = sscanf(s, "%d-%d-%dT%d:%d:%d%c%d:%d", &y, &mo, &d, &h, &mi, &sec, &sign, &oh, &om);
|
|
||||||
if (n < 5)
|
|
||||||
return 0;
|
|
||||||
int64_t ts = int64_t(days_from_civil(y, mo, d)) * 86400 + h * 3600 + mi * 60 + sec;
|
|
||||||
if (n >= 8 && (sign == '+' || sign == '-')) {
|
|
||||||
int64_t offset = oh * 3600 + om * 60;
|
|
||||||
ts += sign == '+' ? -offset : offset;
|
|
||||||
}
|
|
||||||
return ts > 0 ? uint32_t(ts) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the weather.get_forecasts response for the given entity;
|
|
||||||
// Home Assistant wraps the action response as {"response": {...}}
|
|
||||||
inline bool parse(ArduinoJson::JsonObjectConst response, const char *entity, uint32_t now, Forecast &fc) {
|
|
||||||
ArduinoJson::JsonObjectConst root = response["response"].is<ArduinoJson::JsonObjectConst>()
|
|
||||||
? response["response"].as<ArduinoJson::JsonObjectConst>()
|
|
||||||
: response;
|
|
||||||
ArduinoJson::JsonArrayConst items = root[entity]["forecast"];
|
|
||||||
if (items.isNull() || items.size() == 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
Forecast out;
|
|
||||||
for (ArduinoJson::JsonObjectConst item : items) {
|
|
||||||
if (out.count >= MAX_HOURS)
|
|
||||||
break;
|
|
||||||
if (out.count == 0) {
|
|
||||||
out.first_ts = parse_iso8601(item["datetime"] | "");
|
|
||||||
if (out.first_ts == 0)
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Hour &hour = out.hours[out.count++];
|
|
||||||
hour.cond = condition_code(item["condition"] | "");
|
|
||||||
float temp = item["temperature"] | NAN;
|
|
||||||
hour.temp2 = std::isnan(temp) ? NO_TEMP : int8_t(std::max(-127L, std::min(127L, lroundf(temp * 2))));
|
|
||||||
float wind = item["wind_speed"] | NAN;
|
|
||||||
hour.wind = std::isnan(wind) ? NO_VALUE : uint8_t(std::max(0L, std::min(254L, lroundf(wind))));
|
|
||||||
float pop = item["precipitation_probability"] | NAN;
|
|
||||||
hour.pop = std::isnan(pop) ? NO_VALUE : uint8_t(std::max(0L, std::min(100L, lroundf(pop))));
|
|
||||||
}
|
|
||||||
out.fetched_at = now;
|
|
||||||
fc = out;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool load(esphome::i2c::I2CBus *bus, Forecast &fc) {
|
|
||||||
uint8_t buf[MAX_SIZE];
|
|
||||||
const uint8_t start = 0;
|
|
||||||
fc.count = 0;
|
|
||||||
if (bus->write_readv(EEPROM_ADDR, &start, 1, buf, sizeof(buf)) != esphome::i2c::ERROR_OK)
|
|
||||||
return false;
|
|
||||||
if (buf[0] != MAGIC || buf[1] != VERSION)
|
|
||||||
return false;
|
|
||||||
memcpy(&fc.fetched_at, buf + 2, 4);
|
|
||||||
memcpy(&fc.first_ts, buf + 6, 4);
|
|
||||||
fc.count = std::min(buf[10], MAX_HOURS);
|
|
||||||
memcpy(fc.hours, buf + HEADER, fc.count * sizeof(Hour));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool save(esphome::i2c::I2CBus *bus, const Forecast &fc) {
|
|
||||||
uint8_t buf[MAX_SIZE];
|
|
||||||
buf[0] = MAGIC;
|
|
||||||
buf[1] = VERSION;
|
|
||||||
memcpy(buf + 2, &fc.fetched_at, 4);
|
|
||||||
memcpy(buf + 6, &fc.first_ts, 4);
|
|
||||||
buf[10] = fc.count;
|
|
||||||
memcpy(buf + HEADER, fc.hours, fc.count * sizeof(Hour));
|
|
||||||
const size_t len = HEADER + fc.count * sizeof(Hour);
|
|
||||||
|
|
||||||
// page writes must not cross an 8-byte page boundary; 5 ms write cycle per page
|
|
||||||
for (size_t off = 0; off < len; off += EEPROM_PAGE) {
|
|
||||||
uint8_t page[EEPROM_PAGE + 1];
|
|
||||||
const size_t n = std::min<size_t>(EEPROM_PAGE, len - off);
|
|
||||||
page[0] = uint8_t(off);
|
|
||||||
memcpy(page + 1, buf + off, n);
|
|
||||||
if (bus->write(EEPROM_ADDR, page, n + 1) != esphome::i2c::ERROR_OK)
|
|
||||||
return false;
|
|
||||||
esphome::delay(6);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace m5paper_weather
|
|
||||||
@@ -20,6 +20,12 @@ esp32:
|
|||||||
packages:
|
packages:
|
||||||
common: !include common/common.yaml
|
common: !include common/common.yaml
|
||||||
|
|
||||||
|
external_components:
|
||||||
|
# speaker media player: skip a failed playlist item (esphome#19862), drop once merged
|
||||||
|
- source: github://randybb/esphome-components
|
||||||
|
components: [speaker]
|
||||||
|
refresh: 1h
|
||||||
|
|
||||||
api:
|
api:
|
||||||
actions:
|
actions:
|
||||||
# https://github.com/granadaxronos/120-SONG_NOKIA_RTTTL_RINGTONE_PLAYER_FOR_ARDUINO_UNO/blob/master/RTTTL_PLAYER/songs.h
|
# https://github.com/granadaxronos/120-SONG_NOKIA_RTTTL_RINGTONE_PLAYER_FOR_ARDUINO_UNO/blob/master/RTTTL_PLAYER/songs.h
|
||||||
|
|||||||
+8
-3
@@ -21,6 +21,12 @@ esp32:
|
|||||||
packages:
|
packages:
|
||||||
common: !include common/common.yaml
|
common: !include common/common.yaml
|
||||||
|
|
||||||
|
external_components:
|
||||||
|
# speaker media player: skip a failed playlist item (esphome#19862), drop once merged
|
||||||
|
- source: github://randybb/esphome-components
|
||||||
|
components: [speaker]
|
||||||
|
refresh: 1h
|
||||||
|
|
||||||
api:
|
api:
|
||||||
actions:
|
actions:
|
||||||
# https://github.com/granadaxronos/120-SONG_NOKIA_RTTTL_RINGTONE_PLAYER_FOR_ARDUINO_UNO/blob/master/RTTTL_PLAYER/songs.h
|
# https://github.com/granadaxronos/120-SONG_NOKIA_RTTTL_RINGTONE_PLAYER_FOR_ARDUINO_UNO/blob/master/RTTTL_PLAYER/songs.h
|
||||||
@@ -127,16 +133,15 @@ media_player:
|
|||||||
- platform: speaker
|
- platform: speaker
|
||||||
id: media_out
|
id: media_out
|
||||||
name: Player
|
name: Player
|
||||||
codec_support_enabled: false
|
|
||||||
buffer_size: 14000
|
buffer_size: 14000
|
||||||
media_pipeline:
|
media_pipeline:
|
||||||
speaker: media_spk_resampling_input
|
speaker: media_spk_resampling_input
|
||||||
# format: WAV
|
format: WAV # only the WAV decoder is built (was codec_support_enabled: false)
|
||||||
# num_channels: 1
|
# num_channels: 1
|
||||||
# sample_rate: 8000 # Lower sample rate
|
# sample_rate: 8000 # Lower sample rate
|
||||||
announcement_pipeline:
|
announcement_pipeline:
|
||||||
speaker: announcement_spk_resampling_input
|
speaker: announcement_spk_resampling_input
|
||||||
# format: WAV
|
format: WAV
|
||||||
# num_channels: 1
|
# num_channels: 1
|
||||||
# sample_rate: 8000 # Lower sample rate
|
# sample_rate: 8000 # Lower sample rate
|
||||||
on_play:
|
on_play:
|
||||||
|
|||||||
+194
-179
@@ -4,13 +4,18 @@ substitutions:
|
|||||||
name: M5Paper
|
name: M5Paper
|
||||||
area: DEV
|
area: DEV
|
||||||
comment: M5Paper
|
comment: M5Paper
|
||||||
weather_entity: weather.openweathermap
|
waste_calendar: calendar.https_www_hviezdoslavov_sk_kalendar_icalfeed_category_0_ongoing_1
|
||||||
|
# contacts + manual
|
||||||
|
birthday_calendars: calendar.birthdays, calendar.narodeniny
|
||||||
|
|
||||||
# Power modes
|
# Power modes
|
||||||
# - USB: always on, WiFi + API, forecast refreshed every hour and redrawn every 15 min
|
# - USB: always on, WiFi + API, events refreshed and redrawn after midnight
|
||||||
# - battery: RTC wakes the board every 15 min (off 1:00-5:00), it draws the forecast
|
# - battery: RTC wakes the board at 00:05, it refreshes the events, draws them and powers
|
||||||
# from EEPROM and powers itself off; WiFi only to refresh the forecast every 6 h
|
# itself off; the RTC timer lasts at most 255 min, so the wake-ups on the way to
|
||||||
# Screens: status (after power-on, until Home Assistant connects) and forecast
|
# midnight only re-arm it
|
||||||
|
# The power button (and USB) also connects to WiFi: the events are refreshed on every
|
||||||
|
# Home Assistant connection outside the battery cycle.
|
||||||
|
# Screens: status (after power-on, until Home Assistant connects) and calendar
|
||||||
# The mode is detected by trying to power off: on USB the board keeps running.
|
# The mode is detected by trying to power off: on USB the board keeps running.
|
||||||
|
|
||||||
esphome:
|
esphome:
|
||||||
@@ -19,7 +24,7 @@ esphome:
|
|||||||
area: ${area}
|
area: ${area}
|
||||||
comment: ${comment}
|
comment: ${comment}
|
||||||
includes:
|
includes:
|
||||||
- include/m5paper_weather.h
|
- include/m5paper_calendar.h
|
||||||
on_boot:
|
on_boot:
|
||||||
- priority: 600
|
- priority: 600
|
||||||
then:
|
then:
|
||||||
@@ -33,8 +38,8 @@ esphome:
|
|||||||
const uint8_t clear[2] = {reg, uint8_t(status & ~0x05)};
|
const uint8_t clear[2] = {reg, uint8_t(status & ~0x05)};
|
||||||
id(i2c_main)->write(0x51, clear, 2);
|
id(i2c_main)->write(0x51, clear, 2);
|
||||||
}
|
}
|
||||||
m5paper_weather::load(id(i2c_main), m5paper_weather::forecast());
|
m5paper_calendar::load(m5paper_calendar::events());
|
||||||
ESP_LOGI("m5paper", "Wake-up: %s, forecast: %u h", id(rtc_wake) ? "RTC" : "power-on", m5paper_weather::forecast().count);
|
ESP_LOGI("m5paper", "Wake-up: %s, events: %u", id(rtc_wake) ? "RTC" : "power-on", m5paper_calendar::events().count);
|
||||||
- priority: -100
|
- priority: -100
|
||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
@@ -65,6 +70,7 @@ psram:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
common: !include common/common.yaml
|
common: !include common/common.yaml
|
||||||
|
ipv6_only: !include common/ipv6_only.yaml
|
||||||
|
|
||||||
globals:
|
globals:
|
||||||
- id: show_status
|
- id: show_status
|
||||||
@@ -76,8 +82,8 @@ globals:
|
|||||||
- id: rtc_wake
|
- id: rtc_wake
|
||||||
type: bool
|
type: bool
|
||||||
initial_value: 'false'
|
initial_value: 'false'
|
||||||
# battery cycle running: forecast screen regardless of the connection state
|
# battery cycle running: calendar screen regardless of the connection state
|
||||||
- id: weather_page
|
- id: calendar_page
|
||||||
type: bool
|
type: bool
|
||||||
initial_value: 'false'
|
initial_value: 'false'
|
||||||
- id: fetch_done
|
- id: fetch_done
|
||||||
@@ -92,7 +98,7 @@ wifi:
|
|||||||
on_connect:
|
on_connect:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return id(show_status) && !id(weather_page);
|
lambda: return id(show_status) && !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
|
|
||||||
@@ -108,21 +114,18 @@ api:
|
|||||||
- lambda: id(show_status) = false;
|
- lambda: id(show_status) = false;
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return !id(weather_page);
|
lambda: return !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
# outside the battery cycle: refresh a forecast older than an hour
|
# outside the battery cycle (power button, USB) WiFi is up anyway: always refresh
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
and:
|
and:
|
||||||
- lambda: |-
|
- lambda: return !id(calendar_page);
|
||||||
const auto &fc = m5paper_weather::forecast();
|
|
||||||
const auto now = id(rtc_time).now();
|
|
||||||
return !id(weather_page) && (!fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 3600);
|
|
||||||
- api.connected:
|
- api.connected:
|
||||||
state_subscription_only: true
|
state_subscription_only: true
|
||||||
then:
|
then:
|
||||||
- script.execute: fetch_forecast
|
- script.execute: fetch_events
|
||||||
on_client_disconnected:
|
on_client_disconnected:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
@@ -133,13 +136,13 @@ api:
|
|||||||
- lambda: id(show_status) = true;
|
- lambda: id(show_status) = true;
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return !id(weather_page);
|
lambda: return !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
|
|
||||||
# I2C devices (i2c_main)
|
# I2C devices (i2c_main)
|
||||||
# 0x44 SHT30
|
# 0x44 SHT30
|
||||||
# 0x50 FM24C02 EEPROM (forecast storage, see include/m5paper_weather.h)
|
# 0x50 FM24C02 EEPROM (unused)
|
||||||
# 0x51 BM8563 RTC
|
# 0x51 BM8563 RTC
|
||||||
# 0x5D GT911 touch (docs say 0x14; the driver tries 0x5D, then 0x14)
|
# 0x5D GT911 touch (docs say 0x14; the driver tries 0x5D, then 0x14)
|
||||||
|
|
||||||
@@ -185,76 +188,80 @@ switch:
|
|||||||
internal: true
|
internal: true
|
||||||
|
|
||||||
script:
|
script:
|
||||||
# One wake-up on battery: refresh the forecast if it is old, draw it, power off
|
# One wake-up on battery: refresh the events if they are old, draw them, power off
|
||||||
- id: battery_cycle
|
- id: battery_cycle
|
||||||
mode: single
|
mode: single
|
||||||
then:
|
then:
|
||||||
- lambda: id(weather_page) = true;
|
- lambda: id(calendar_page) = true;
|
||||||
- wait_until:
|
- wait_until:
|
||||||
condition:
|
condition:
|
||||||
lambda: return id(rtc_time).now().is_valid();
|
lambda: return id(rtc_time).now().is_valid();
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
- if:
|
- if:
|
||||||
|
# RTC wake-up on the way to midnight: nothing to draw
|
||||||
condition:
|
condition:
|
||||||
lambda: |-
|
lambda: |-
|
||||||
const auto &fc = m5paper_weather::forecast();
|
|
||||||
const auto now = id(rtc_time).now();
|
const auto now = id(rtc_time).now();
|
||||||
return !fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 6 * 3600 - 600;
|
return id(rtc_wake) && now.is_valid() && now.hour != 0;
|
||||||
then:
|
then:
|
||||||
- wifi.enable:
|
- script.execute: try_sleep
|
||||||
- wait_until:
|
else:
|
||||||
condition:
|
|
||||||
api.connected:
|
|
||||||
state_subscription_only: true
|
|
||||||
timeout: 45s
|
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
api.connected:
|
lambda: return m5paper_calendar::stale(id(rtc_time).now());
|
||||||
state_subscription_only: true
|
|
||||||
then:
|
then:
|
||||||
- script.execute: fetch_forecast
|
- wifi.enable:
|
||||||
- wait_until:
|
- wait_until:
|
||||||
condition:
|
condition:
|
||||||
lambda: return id(fetch_done);
|
api.connected:
|
||||||
timeout: 15s
|
state_subscription_only: true
|
||||||
- wait_until:
|
timeout: 45s
|
||||||
condition:
|
- if:
|
||||||
lambda: return !std::isnan(id(current_temperature).state);
|
condition:
|
||||||
timeout: 5s
|
api.connected:
|
||||||
- component.update: m5paper_display
|
state_subscription_only: true
|
||||||
# let the e-paper refresh finish before cutting power
|
then:
|
||||||
- delay: 4s
|
- script.execute: fetch_events
|
||||||
- script.execute: try_sleep
|
- wait_until:
|
||||||
|
condition:
|
||||||
|
lambda: return id(fetch_done);
|
||||||
|
timeout: 15s
|
||||||
|
- component.update: m5paper_display
|
||||||
|
# let the e-paper refresh finish before cutting power
|
||||||
|
- delay: 4s
|
||||||
|
- script.execute: try_sleep
|
||||||
|
|
||||||
- id: fetch_forecast
|
# today and the next 31 days (the month grid and the list)
|
||||||
|
- id: fetch_events
|
||||||
mode: single
|
mode: single
|
||||||
then:
|
then:
|
||||||
- lambda: id(fetch_done) = false;
|
- lambda: id(fetch_done) = false;
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: weather.get_forecasts
|
action: calendar.get_events
|
||||||
data:
|
data:
|
||||||
entity_id: ${weather_entity}
|
entity_id: ${waste_calendar}, ${birthday_calendars}
|
||||||
type: hourly
|
start_date_time: !lambda return id(rtc_time).now().strftime("%Y-%m-%d 00:00:00");
|
||||||
|
duration: "744:00:00"
|
||||||
capture_response: true
|
capture_response: true
|
||||||
on_success:
|
on_success:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
auto &fc = m5paper_weather::forecast();
|
auto &ev = m5paper_calendar::events();
|
||||||
if (m5paper_weather::parse(response, "${weather_entity}", id(rtc_time).now().timestamp, fc)) {
|
if (m5paper_calendar::parse(response, "${waste_calendar}", id(rtc_time).now().timestamp, ev)) {
|
||||||
const bool saved = m5paper_weather::save(id(i2c_main), fc);
|
const bool saved = m5paper_calendar::save(ev);
|
||||||
ESP_LOGI("m5paper", "Forecast: %u h%s", fc.count, saved ? "" : " (EEPROM write failed)");
|
ESP_LOGI("m5paper", "Events: %u%s", ev.count, saved ? "" : " (flash write failed)");
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGW("m5paper", "Forecast: unexpected response");
|
ESP_LOGW("m5paper", "Events: unexpected response");
|
||||||
}
|
}
|
||||||
id(fetch_done) = true;
|
id(fetch_done) = true;
|
||||||
# the battery cycle draws on its own
|
# the battery cycle draws on its own
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return !id(weather_page);
|
lambda: return !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
on_error:
|
on_error:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
ESP_LOGW("m5paper", "Forecast: %s", error.c_str());
|
ESP_LOGW("m5paper", "Events: %s", error.c_str());
|
||||||
id(fetch_done) = true;
|
id(fetch_done) = true;
|
||||||
|
|
||||||
# Arm the RTC and cut power; if we are still running afterwards, we are on USB
|
# Arm the RTC and cut power; if we are still running afterwards, we are on USB
|
||||||
@@ -262,22 +269,21 @@ script:
|
|||||||
mode: single
|
mode: single
|
||||||
then:
|
then:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
// next quarter hour, 1:00-5:00 in one go
|
// next 00:05; the timer lasts at most 255 min, so get there in steps
|
||||||
int duration = 900;
|
const int MAX = 255 * 60;
|
||||||
|
int duration = MAX;
|
||||||
const auto now = id(rtc_time).now();
|
const auto now = id(rtc_time).now();
|
||||||
if (now.is_valid()) {
|
if (now.is_valid()) {
|
||||||
const int sod = now.hour * 3600 + now.minute * 60 + now.second;
|
const int sod = now.hour * 3600 + now.minute * 60 + now.second;
|
||||||
int next = (sod / 900 + 1) * 900;
|
int left = (86400 + 300 - sod) % 86400;
|
||||||
if (next - sod < 90) // the 1/60 Hz RTC timer may wake us up to a minute early
|
if (left < 600) // just drawn: the 1/60 Hz RTC timer may wake us up to a minute early
|
||||||
next += 900;
|
left += 86400;
|
||||||
const int next_hour = (next / 3600) % 24;
|
// leave at least 20 min for the last step, so it cannot fall into the case above
|
||||||
if (next_hour >= 1 && next_hour < 5)
|
duration = left > MAX ? std::min(MAX, left - 1200) : left;
|
||||||
next = (next / 86400) * 86400 + 5 * 3600;
|
|
||||||
duration = next - sod;
|
|
||||||
}
|
}
|
||||||
if (duration > 255) // longer timers count whole minutes
|
if (duration > 255) // longer timers count whole minutes
|
||||||
duration = (duration + 59) / 60 * 60;
|
duration = (duration + 59) / 60 * 60;
|
||||||
id(sleep_s) = std::min(duration, 255 * 60);
|
id(sleep_s) = std::min(duration, MAX);
|
||||||
ESP_LOGI("m5paper", "Powering off for %d s", id(sleep_s));
|
ESP_LOGI("m5paper", "Powering off for %d s", id(sleep_s));
|
||||||
- bm8563.start_timer:
|
- bm8563.start_timer:
|
||||||
duration: !lambda return id(sleep_s);
|
duration: !lambda return id(sleep_s);
|
||||||
@@ -292,7 +298,8 @@ script:
|
|||||||
- lambda: |-
|
- lambda: |-
|
||||||
ESP_LOGI("m5paper", "Still running, USB power");
|
ESP_LOGI("m5paper", "Still running, USB power");
|
||||||
id(usb_mode) = true;
|
id(usb_mode) = true;
|
||||||
id(weather_page) = false;
|
id(rtc_wake) = false;
|
||||||
|
id(calendar_page) = false;
|
||||||
id(show_status) = !api::global_api_server->is_connected_with_state_subscription();
|
id(show_status) = !api::global_api_server->is_connected_with_state_subscription();
|
||||||
- wifi.enable:
|
- wifi.enable:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
@@ -321,21 +328,24 @@ time:
|
|||||||
- bm8563.write_time
|
- bm8563.write_time
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return !id(weather_page);
|
lambda: return !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
on_time:
|
on_time:
|
||||||
|
# new day
|
||||||
- seconds: 0
|
- seconds: 0
|
||||||
minutes: /15
|
minutes: 0
|
||||||
|
hours: 0
|
||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
lambda: return !id(show_status) && !id(weather_page);
|
lambda: return !id(show_status) && !id(calendar_page);
|
||||||
then:
|
then:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
# USB mode: keep the stored forecast fresh for battery mode
|
# USB mode: keep the stored events fresh for battery mode
|
||||||
- seconds: 0
|
- seconds: 0
|
||||||
minutes: 5
|
minutes: 5
|
||||||
|
hours: 0
|
||||||
then:
|
then:
|
||||||
- if:
|
- if:
|
||||||
condition:
|
condition:
|
||||||
@@ -344,7 +354,7 @@ time:
|
|||||||
- api.connected:
|
- api.connected:
|
||||||
state_subscription_only: true
|
state_subscription_only: true
|
||||||
then:
|
then:
|
||||||
- script.execute: fetch_forecast
|
- script.execute: fetch_events
|
||||||
- platform: bm8563
|
- platform: bm8563
|
||||||
id: rtc_time
|
id: rtc_time
|
||||||
|
|
||||||
@@ -388,10 +398,10 @@ button:
|
|||||||
on_press:
|
on_press:
|
||||||
- component.update: m5paper_display
|
- component.update: m5paper_display
|
||||||
- platform: template
|
- platform: template
|
||||||
name: Refresh Forecast
|
name: Refresh Calendar
|
||||||
entity_category: config
|
entity_category: config
|
||||||
on_press:
|
on_press:
|
||||||
- script.execute: fetch_forecast
|
- script.execute: fetch_events
|
||||||
|
|
||||||
sensor:
|
sensor:
|
||||||
- platform: sht3xd
|
- platform: sht3xd
|
||||||
@@ -429,8 +439,7 @@ sensor:
|
|||||||
max_value: 100
|
max_value: 100
|
||||||
|
|
||||||
.image_defaults:
|
.image_defaults:
|
||||||
icon_large: &icon_large {platform: file, type: BINARY, transparency: chroma_key, resize: 150x150}
|
icon: &icon {platform: file, type: BINARY, transparency: chroma_key}
|
||||||
icon_small: &icon_small {platform: file, type: BINARY, transparency: chroma_key, resize: 44x44}
|
|
||||||
|
|
||||||
image:
|
image:
|
||||||
- platform: file
|
- platform: file
|
||||||
@@ -439,37 +448,11 @@ image:
|
|||||||
resize: 460x136
|
resize: 460x136
|
||||||
type: GRAYSCALE
|
type: GRAYSCALE
|
||||||
transparency: alpha_channel
|
transparency: alpha_channel
|
||||||
# weather icons, same order as m5paper_weather::CONDITIONS
|
# same order as m5paper_calendar::Kind
|
||||||
- {<<: *icon_large, file: "mdi:weather-night", id: wl_clear_night}
|
- {<<: *icon, file: "mdi:cake-variant", id: icon_birthday, resize: 32x32}
|
||||||
- {<<: *icon_large, file: "mdi:weather-cloudy", id: wl_cloudy}
|
- {<<: *icon, file: "mdi:trash-can", id: icon_waste, resize: 32x32}
|
||||||
- {<<: *icon_large, file: "mdi:alert-circle-outline", id: wl_exceptional}
|
- {<<: *icon, file: "mdi:cake-variant", id: mark_birthday, resize: 18x18}
|
||||||
- {<<: *icon_large, file: "mdi:weather-fog", id: wl_fog}
|
- {<<: *icon, file: "mdi:trash-can", id: mark_waste, resize: 18x18}
|
||||||
- {<<: *icon_large, file: "mdi:weather-hail", id: wl_hail}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-lightning", id: wl_lightning}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-lightning-rainy", id: wl_lightning_rainy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-partly-cloudy", id: wl_partlycloudy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-pouring", id: wl_pouring}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-rainy", id: wl_rainy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-snowy", id: wl_snowy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-snowy-rainy", id: wl_snowy_rainy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-sunny", id: wl_sunny}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-windy", id: wl_windy}
|
|
||||||
- {<<: *icon_large, file: "mdi:weather-windy-variant", id: wl_windy_variant}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-night", id: ws_clear_night}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-cloudy", id: ws_cloudy}
|
|
||||||
- {<<: *icon_small, file: "mdi:alert-circle-outline", id: ws_exceptional}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-fog", id: ws_fog}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-hail", id: ws_hail}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-lightning", id: ws_lightning}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-lightning-rainy", id: ws_lightning_rainy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-partly-cloudy", id: ws_partlycloudy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-pouring", id: ws_pouring}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-rainy", id: ws_rainy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-snowy", id: ws_snowy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-snowy-rainy", id: ws_snowy_rainy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-sunny", id: ws_sunny}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-windy", id: ws_windy}
|
|
||||||
- {<<: *icon_small, file: "mdi:weather-windy-variant", id: ws_windy_variant}
|
|
||||||
|
|
||||||
font:
|
font:
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
@@ -486,22 +469,14 @@ font:
|
|||||||
glyphs: *sk_glyphs
|
glyphs: *sk_glyphs
|
||||||
glyphsets: [GF_Latin_Kernel]
|
glyphsets: [GF_Latin_Kernel]
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: font_tiny
|
id: font_footer
|
||||||
size: 18
|
size: 18
|
||||||
glyphs: "0123456789% "
|
glyphs: "0123456789abcdef:. "
|
||||||
- file: "gfonts://Roboto@500"
|
- file: "gfonts://Roboto@500"
|
||||||
id: font_date
|
id: font_date
|
||||||
size: 34
|
size: 34
|
||||||
glyphs: *sk_glyphs
|
glyphs: *sk_glyphs
|
||||||
glyphsets: [GF_Latin_Kernel]
|
glyphsets: [GF_Latin_Kernel]
|
||||||
- file: "gfonts://Roboto@500"
|
|
||||||
id: font_hour
|
|
||||||
size: 28
|
|
||||||
glyphs: "0123456789-°"
|
|
||||||
- file: "gfonts://Roboto"
|
|
||||||
id: font_temp
|
|
||||||
size: 110
|
|
||||||
glyphs: "0123456789.,-°C "
|
|
||||||
|
|
||||||
color:
|
color:
|
||||||
- id: ink
|
- id: ink
|
||||||
@@ -528,11 +503,9 @@ display:
|
|||||||
const int w = it.get_width();
|
const int w = it.get_width();
|
||||||
const int cx = w / 2;
|
const int cx = w / 2;
|
||||||
const float batt = id(battery_level).state;
|
const float batt = id(battery_level).state;
|
||||||
const float temp = id(current_temperature).state;
|
|
||||||
const float hum = id(current_humidity).state;
|
|
||||||
|
|
||||||
// status screen after power-on, until Home Assistant connects
|
// status screen after power-on, until Home Assistant connects
|
||||||
if (id(show_status) && !id(weather_page)) {
|
if (id(show_status) && !id(calendar_page)) {
|
||||||
it.image(cx, 200, id(esphome_logo), ImageAlign::CENTER);
|
it.image(cx, 200, id(esphome_logo), ImageAlign::CENTER);
|
||||||
it.print(cx, 320, id(font_title), id(ink), TextAlign::CENTER, "${name}");
|
it.print(cx, 320, id(font_title), id(ink), TextAlign::CENTER, "${name}");
|
||||||
|
|
||||||
@@ -561,83 +534,125 @@ display:
|
|||||||
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
|
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
|
||||||
if (!std::isnan(batt))
|
if (!std::isnan(batt))
|
||||||
it.printf(40, 670, id(font_status), id(ink), "Battery: %.0f%% (%.2f V)", batt, id(battery_voltage).state);
|
it.printf(40, 670, id(font_status), id(ink), "Battery: %.0f%% (%.2f V)", batt, id(battery_voltage).state);
|
||||||
it.printf(40, 720, id(font_status), id(ink), "Forecast: %u h stored", m5paper_weather::forecast().count);
|
it.printf(40, 720, id(font_status), id(ink), "Calendar: %u events stored", m5paper_calendar::events().count);
|
||||||
it.printf(40, 900, id(font_small), id(ink), "ESPHome %s", ESPHOME_VERSION);
|
it.printf(40, 900, id(font_small), id(ink), "ESPHome %s", ESPHOME_VERSION);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// forecast screen
|
// calendar screen
|
||||||
using namespace m5paper_weather;
|
using namespace m5paper_calendar;
|
||||||
image::Image *large[] = {id(wl_clear_night), id(wl_cloudy), id(wl_exceptional), id(wl_fog),
|
image::Image *icons[] = {id(icon_birthday), id(icon_waste)};
|
||||||
id(wl_hail), id(wl_lightning), id(wl_lightning_rainy), id(wl_partlycloudy),
|
image::Image *marks[] = {id(mark_birthday), id(mark_waste)};
|
||||||
id(wl_pouring), id(wl_rainy), id(wl_snowy), id(wl_snowy_rainy),
|
static const char *const DAYS[] = {"Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"};
|
||||||
id(wl_sunny), id(wl_windy), id(wl_windy_variant)};
|
static const char *const DAYS_SHORT[] = {"Po", "Ut", "St", "Št", "Pi", "So", "Ne"};
|
||||||
image::Image *small[] = {id(ws_clear_night), id(ws_cloudy), id(ws_exceptional), id(ws_fog),
|
static const char *const MONTHS[] = {"Január", "Február", "Marec", "Apríl", "Máj", "Jún",
|
||||||
id(ws_hail), id(ws_lightning), id(ws_lightning_rainy), id(ws_partlycloudy),
|
"Júl", "August", "September", "Október", "November", "December"};
|
||||||
id(ws_pouring), id(ws_rainy), id(ws_snowy), id(ws_snowy_rainy),
|
|
||||||
id(ws_sunny), id(ws_windy), id(ws_windy_variant)};
|
|
||||||
static const char *const DAYS[] = {"Nedeľa", "Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota"};
|
|
||||||
|
|
||||||
const auto &fc = m5paper_weather::forecast();
|
const auto &ev = m5paper_calendar::events();
|
||||||
const auto now = id(rtc_time).now();
|
const auto now = id(rtc_time).now();
|
||||||
|
if (!now.is_valid()) {
|
||||||
|
it.print(cx, 400, id(font_status), id(ink), TextAlign::CENTER, "Čas nie je k dispozícii");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int32_t today = local_day(now);
|
||||||
|
|
||||||
// header: date, battery
|
// header: date, battery
|
||||||
if (now.is_valid())
|
it.printf(24, 16, id(font_date), id(ink), "%s %d. %d.", DAYS[weekday(today)], now.day_of_month, now.month);
|
||||||
it.printf(24, 16, id(font_date), id(ink), "%s %d. %d.", DAYS[(now.day_of_week + 6) % 7], now.day_of_month,
|
|
||||||
now.month);
|
|
||||||
if (!std::isnan(batt))
|
if (!std::isnan(batt))
|
||||||
it.printf(w - 24, 22, id(font_small), id(ink), TextAlign::TOP_RIGHT, "%.0f %%", batt);
|
it.printf(w - 24, 22, id(font_small), id(ink), TextAlign::TOP_RIGHT, "%.0f %%", batt);
|
||||||
|
|
||||||
const Hour *cur = now.is_valid() ? fc.at(now.timestamp) : nullptr;
|
// month grid; days with events get the icons of their kinds
|
||||||
if (cur == nullptr) {
|
const int32_t first = days_from_civil(now.year, now.month, 1);
|
||||||
it.print(cx, 230, id(font_status), id(ink), TextAlign::CENTER, "Predpoveď nie je k dispozícii");
|
const int32_t next = now.month == 12 ? days_from_civil(now.year + 1, 1, 1) : days_from_civil(now.year, now.month + 1, 1);
|
||||||
} else {
|
const int dim = next - first, lead = weekday(first);
|
||||||
// current hour
|
uint8_t kinds[32] = {};
|
||||||
if (cur->cond < NUM_CONDITIONS)
|
for (int i = 0; i < ev.count; i++) {
|
||||||
it.image(24, 80, large[cur->cond], id(ink));
|
if (ev.items[i].day >= first && ev.items[i].day < next)
|
||||||
if (cur->temp2 != NO_TEMP)
|
kinds[ev.items[i].day - first + 1] |= 1 << ev.items[i].kind;
|
||||||
it.printf(200, 70, id(font_temp), id(ink), "%.0f°", cur->temp2 / 2.0f);
|
}
|
||||||
it.print(204, 200, id(font_status), id(ink), condition_name(cur->cond));
|
const int col_w = 70, row_h = 54, gx = (w - 7 * col_w) / 2, gy = 150;
|
||||||
if (cur->wind != NO_VALUE)
|
it.printf(cx, 70, id(font_status), id(ink), TextAlign::TOP_CENTER, "%s %d", MONTHS[now.month - 1], now.year);
|
||||||
it.printf(204, 245, id(font_small), id(ink), "vietor %u km/h", cur->wind);
|
for (int c = 0; c < 7; c++)
|
||||||
if (cur->pop != NO_VALUE)
|
it.print(gx + c * col_w + col_w / 2, 116, id(font_small), id(ink), TextAlign::TOP_CENTER, DAYS_SHORT[c]);
|
||||||
it.printf(204, 280, id(font_small), id(ink), "zrážky %u %%", cur->pop);
|
for (int d = 1; d <= dim; d++) {
|
||||||
|
const int cell = lead + d - 1;
|
||||||
// next 24 hours: 3 columns x 8 rows
|
const int x = gx + (cell % 7) * col_w, y = gy + (cell / 7) * row_h;
|
||||||
const uint32_t first = (now.timestamp / 3600 + 1) * 3600;
|
const bool is_today = first + d - 1 == today;
|
||||||
const int top = 440, row_h = 60, col_w = w / 3;
|
if (is_today)
|
||||||
for (int i = 0; i < 24; i++) {
|
it.filled_rectangle(x + 3, y + 1, col_w - 6, row_h - 2, id(ink));
|
||||||
const Hour *h = fc.at(first + i * 3600);
|
const Color fg = is_today ? id(paper) : id(ink);
|
||||||
if (h == nullptr)
|
it.printf(x + col_w / 2, y + 4, id(font_small), fg, TextAlign::TOP_CENTER, "%d", d);
|
||||||
break;
|
const int n = __builtin_popcount(kinds[d]);
|
||||||
const int x = (i / 8) * col_w + 12;
|
int mx = x + (col_w - n * 20) / 2;
|
||||||
const int y = top + (i % 8) * row_h;
|
for (int k = 0; k < 2; k++) {
|
||||||
const auto t = ESPTime::from_epoch_local(first + i * 3600);
|
if (kinds[d] & (1 << k)) {
|
||||||
it.printf(x, y + 14, id(font_hour), id(ink), "%02d", t.hour);
|
it.image(mx, y + 32, marks[k], fg);
|
||||||
if (h->cond < NUM_CONDITIONS)
|
mx += 20;
|
||||||
it.image(x + 42, y + 8, small[h->cond], id(ink));
|
}
|
||||||
if (h->temp2 != NO_TEMP)
|
|
||||||
it.printf(x + 94, y + 6, id(font_hour), id(ink), "%.0f°", h->temp2 / 2.0f);
|
|
||||||
if (h->pop != NO_VALUE && h->pop >= 10)
|
|
||||||
it.printf(x + 94, y + 38, id(font_tiny), id(ink), "%u %%", h->pop);
|
|
||||||
}
|
}
|
||||||
for (int c = 1; c < 3; c++)
|
|
||||||
it.vertical_line(c * col_w, top, 8 * row_h, id(ink));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// indoor
|
// next 14 days; waste collection today or tomorrow is inverted: put the bin out
|
||||||
it.horizontal_line(24, 340, w - 48, id(ink));
|
const int top = 490, list_h = 50, max_rows = 8;
|
||||||
it.printf(cx, 380, id(font_status), id(ink), TextAlign::CENTER, "Doma %.1f °C · %.0f %%", temp, hum);
|
it.horizontal_line(24, top - 12, w - 48, id(ink));
|
||||||
it.horizontal_line(24, 420, w - 48, id(ink));
|
int rows = 0, more = 0;
|
||||||
|
for (int i = 0; i < ev.count; i++) {
|
||||||
|
const Event &e = ev.items[i];
|
||||||
|
if (e.day < today || e.day >= today + 14)
|
||||||
|
continue;
|
||||||
|
if (rows == max_rows) {
|
||||||
|
more++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int y = top + rows++ * list_h;
|
||||||
|
const bool urgent = e.kind == WASTE && e.day <= today + 1;
|
||||||
|
if (urgent)
|
||||||
|
it.filled_rectangle(16, y, w - 32, list_h - 4, id(ink));
|
||||||
|
const Color fg = urgent ? id(paper) : id(ink);
|
||||||
|
const int ty = y + (list_h - 4) / 2;
|
||||||
|
if (e.day == today)
|
||||||
|
it.print(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "Dnes");
|
||||||
|
else if (e.day == today + 1)
|
||||||
|
it.print(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "Zajtra");
|
||||||
|
else {
|
||||||
|
const auto t = ESPTime::from_epoch_local(uint32_t(e.day) * 86400 + 43200);
|
||||||
|
it.printf(28, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "%s %d. %d.", DAYS_SHORT[weekday(e.day)],
|
||||||
|
t.day_of_month, t.month);
|
||||||
|
}
|
||||||
|
if (e.kind < 2)
|
||||||
|
it.image(150, ty - 16, icons[e.kind], fg);
|
||||||
|
it.start_clipping(194, y, w - 28, y + list_h);
|
||||||
|
if (e.minute == ALL_DAY)
|
||||||
|
it.print(194, ty, id(font_small), fg, TextAlign::CENTER_LEFT, e.summary);
|
||||||
|
else
|
||||||
|
it.printf(194, ty, id(font_small), fg, TextAlign::CENTER_LEFT, "%02d:%02d %s", e.minute / 60, e.minute % 60,
|
||||||
|
e.summary);
|
||||||
|
it.end_clipping();
|
||||||
|
}
|
||||||
|
if (!ev.valid())
|
||||||
|
it.print(cx, top + 20, id(font_small), id(ink), TextAlign::TOP_CENTER, "Kalendár nie je k dispozícii");
|
||||||
|
else if (rows == 0)
|
||||||
|
it.print(cx, top + 20, id(font_small), id(ink), TextAlign::TOP_CENTER, "Žiadne udalosti v najbližších 14 dňoch");
|
||||||
|
else if (more > 0)
|
||||||
|
it.printf(w - 28, top + rows * list_h, id(font_small), id(ink), TextAlign::TOP_RIGHT, "+%d ďalšie", more);
|
||||||
|
|
||||||
// footer
|
// footer: IPv6 address (only while WiFi is up), last download
|
||||||
if (fc.valid()) {
|
it.horizontal_line(24, 910, w - 48, id(ink));
|
||||||
const auto f = ESPTime::from_epoch_local(fc.fetched_at);
|
char buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||||
it.printf(24, 944, id(font_small), id(ink), TextAlign::BOTTOM_LEFT, "predpoveď %d. %d. %02d:%02d",
|
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
|
||||||
f.day_of_month, f.month, f.hour, f.minute);
|
if (!ip.is_set() || !ip.is_ip6())
|
||||||
|
continue;
|
||||||
|
ip.str_to(buf);
|
||||||
|
if (strncmp(buf, "fe80", 4) != 0) {
|
||||||
|
it.print(24, 944, id(font_footer), id(ink), TextAlign::BOTTOM_LEFT, buf);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ev.valid()) {
|
||||||
|
const auto f = ESPTime::from_epoch_local(ev.fetched_at);
|
||||||
|
it.printf(w - 24, 944, id(font_footer), id(ink), TextAlign::BOTTOM_RIGHT, "%d. %d. %02d:%02d", f.day_of_month,
|
||||||
|
f.month, f.hour, f.minute);
|
||||||
}
|
}
|
||||||
if (now.is_valid())
|
|
||||||
it.printf(w - 24, 944, id(font_small), id(ink), TextAlign::BOTTOM_RIGHT, "%02d:%02d", now.hour, now.minute);
|
|
||||||
|
|
||||||
touchscreen:
|
touchscreen:
|
||||||
- platform: gt911
|
- platform: gt911
|
||||||
|
|||||||
@@ -156,8 +156,11 @@ binary_sensor:
|
|||||||
inverted: true
|
inverted: true
|
||||||
on_click:
|
on_click:
|
||||||
if:
|
if:
|
||||||
|
# mcu-patio online -> toggle its lights; otherwise (no API or relay_2 cut its power) the relay
|
||||||
condition:
|
condition:
|
||||||
api.connected:
|
and:
|
||||||
|
- api.connected:
|
||||||
|
- binary_sensor.is_on: patio_status
|
||||||
then:
|
then:
|
||||||
- homeassistant.action:
|
- homeassistant.action:
|
||||||
action: light.toggle
|
action: light.toggle
|
||||||
@@ -169,6 +172,10 @@ binary_sensor:
|
|||||||
else:
|
else:
|
||||||
- switch.toggle: relay_2
|
- switch.toggle: relay_2
|
||||||
|
|
||||||
|
- platform: homeassistant
|
||||||
|
id: patio_status
|
||||||
|
entity_id: binary_sensor.patio_status
|
||||||
|
|
||||||
switch:
|
switch:
|
||||||
- platform: gpio
|
- platform: gpio
|
||||||
name: Outside Light Back
|
name: Outside Light Back
|
||||||
|
|||||||
+2
-2
@@ -40,8 +40,8 @@ external_components:
|
|||||||
components: [axp2101, aw88298, aw9523b, si12t, m5ioe1, ftservo, scs9009]
|
components: [axp2101, aw88298, aw9523b, si12t, m5ioe1, ftservo, scs9009]
|
||||||
refresh: 0s
|
refresh: 0s
|
||||||
- source: github://randybb/esphome-components
|
- source: github://randybb/esphome-components
|
||||||
components: [audio_spectrum]
|
components: [audio_spectrum, speaker] # speaker: esphome#19862, drop once merged
|
||||||
refresh: 1d
|
refresh: 1h
|
||||||
|
|
||||||
globals:
|
globals:
|
||||||
- id: in_love
|
- id: in_love
|
||||||
|
|||||||
+222
-125
@@ -10,9 +10,10 @@ esphome:
|
|||||||
friendly_name: ${name}
|
friendly_name: ${name}
|
||||||
area: ${area}
|
area: ${area}
|
||||||
comment: ${comment}
|
comment: ${comment}
|
||||||
platformio_options:
|
on_boot:
|
||||||
board_build.flash_mode: dio
|
- priority: -100
|
||||||
|
then:
|
||||||
|
- lvgl.page.show: status_page
|
||||||
|
|
||||||
esp32:
|
esp32:
|
||||||
variant: esp32c3
|
variant: esp32c3
|
||||||
@@ -23,39 +24,118 @@ packages:
|
|||||||
common: !include common/common.yaml
|
common: !include common/common.yaml
|
||||||
ipv6_only: !include common/ipv6_only.yaml
|
ipv6_only: !include common/ipv6_only.yaml
|
||||||
|
|
||||||
logger:
|
api:
|
||||||
logs:
|
# status page until Home Assistant connects (log viewers don't subscribe to states)
|
||||||
component: ERROR
|
on_client_connected:
|
||||||
|
- delay: 2s
|
||||||
# GPIO1 UART TX
|
- if:
|
||||||
# GPIO3 UART RX
|
condition:
|
||||||
# GPIO4 I2C SDA
|
api.connected:
|
||||||
# GPIO5 I2C SCL
|
state_subscription_only: true
|
||||||
# GPIO12 DISPLAY DIO
|
then:
|
||||||
# GPIO12 DISPLAY CLOCK
|
- lvgl.page.show: main_page
|
||||||
# GPIO14 BUZZER
|
on_client_disconnected:
|
||||||
# GPIO16 RGB
|
- if:
|
||||||
|
condition:
|
||||||
|
not:
|
||||||
|
api.connected:
|
||||||
|
state_subscription_only: true
|
||||||
|
then:
|
||||||
|
- lvgl.page.show: status_page
|
||||||
|
|
||||||
spi:
|
spi:
|
||||||
clk_pin: 4
|
clk_pin: 4
|
||||||
mosi_pin: 5
|
mosi_pin: 5
|
||||||
miso_pin: 6
|
miso_pin: 6
|
||||||
|
|
||||||
color:
|
display:
|
||||||
- id: my_green
|
- platform: mipi_spi
|
||||||
hex: 00ff00
|
id: main_display
|
||||||
- id: my_magenta
|
model: ILI9341
|
||||||
hex: ff00ff
|
cs_pin: 7
|
||||||
- id: my_red
|
dc_pin: 8
|
||||||
hex: ff0000
|
reset_pin: 10
|
||||||
- id: my_white
|
invert_colors: false
|
||||||
hex: ffffff
|
data_rate: 40MHz
|
||||||
- id: status_ok
|
update_interval: never
|
||||||
hex: 4dd966
|
auto_clear_enabled: false
|
||||||
- id: status_wait
|
|
||||||
hex: ffb300
|
touchscreen:
|
||||||
- id: status_dim
|
- platform: xpt2046
|
||||||
hex: 8c8c8c
|
display: main_display
|
||||||
|
cs_pin: 0
|
||||||
|
interrupt_pin: 1
|
||||||
|
transform:
|
||||||
|
mirror_x: true
|
||||||
|
calibration:
|
||||||
|
x_min: 330
|
||||||
|
x_max: 3620
|
||||||
|
y_min: 230
|
||||||
|
y_max: 3680
|
||||||
|
|
||||||
|
script:
|
||||||
|
- id: update_status
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
// same colors as the other status screens: ok green, waiting orange, details gray
|
||||||
|
static const uint32_t OK = 0x4DD966, WAIT = 0xFFB300;
|
||||||
|
auto set = [](lv_obj_t *label, const std::string &text, uint32_t color) {
|
||||||
|
lv_label_set_text(label, text.c_str());
|
||||||
|
lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bool wifi_ok = wifi::global_wifi_component->is_connected();
|
||||||
|
set(id(status_wifi),
|
||||||
|
wifi_ok ? str_sprintf("WiFi: connected (%d dBm)", wifi::global_wifi_component->wifi_rssi())
|
||||||
|
: std::string("WiFi: connecting..."),
|
||||||
|
wifi_ok ? OK : WAIT);
|
||||||
|
|
||||||
|
// IPv6 first (skip link-local), then IPv4
|
||||||
|
std::string ips;
|
||||||
|
if (wifi_ok) {
|
||||||
|
char buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||||
|
for (bool v6 : {true, false}) {
|
||||||
|
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
|
||||||
|
if (!ip.is_set() || ip.is_ip6() != v6)
|
||||||
|
continue;
|
||||||
|
ip.str_to(buf);
|
||||||
|
if (v6 && strncmp(buf, "fe80", 4) == 0)
|
||||||
|
continue;
|
||||||
|
if (!ips.empty())
|
||||||
|
ips += "\n";
|
||||||
|
ips += buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lv_label_set_text(id(status_ips), ips.c_str());
|
||||||
|
if (ips.empty())
|
||||||
|
lv_obj_add_flag(id(status_ips), LV_OBJ_FLAG_HIDDEN);
|
||||||
|
else
|
||||||
|
lv_obj_remove_flag(id(status_ips), LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
const bool api_ok = api::global_api_server->is_connected();
|
||||||
|
set(id(status_ha), api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...",
|
||||||
|
api_ok ? OK : WAIT);
|
||||||
|
|
||||||
|
const uint32_t up = millis() / 1000;
|
||||||
|
lv_label_set_text(id(status_footer),
|
||||||
|
str_sprintf("ESPHome %s\nup %ud %02u:%02u:%02u", ESPHOME_VERSION, up / 86400,
|
||||||
|
(up / 3600) % 24, (up / 60) % 60, up % 60).c_str());
|
||||||
|
interval:
|
||||||
|
- interval: 1s
|
||||||
|
then:
|
||||||
|
- lvgl.label.update:
|
||||||
|
id: clock_label
|
||||||
|
text: !lambda |-
|
||||||
|
auto now = id(ha_time).now();
|
||||||
|
if (!now.is_valid())
|
||||||
|
return std::string("--:--");
|
||||||
|
return now.strftime("%H:%M");
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
lvgl.page.is_showing: status_page
|
||||||
|
then:
|
||||||
|
- script.execute: update_status
|
||||||
|
|
||||||
image:
|
image:
|
||||||
- platform: file
|
- platform: file
|
||||||
@@ -66,103 +146,120 @@ image:
|
|||||||
transparency: alpha_channel
|
transparency: alpha_channel
|
||||||
|
|
||||||
font:
|
font:
|
||||||
|
- file: "gfonts://Roboto@500"
|
||||||
|
id: font_clock
|
||||||
|
size: 80
|
||||||
|
glyphs: "0123456789:-"
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: font_default
|
id: font_title
|
||||||
size: 20
|
|
||||||
- file: "gfonts://B612"
|
|
||||||
id: b612_24
|
|
||||||
size: 24
|
|
||||||
- file: "gfonts://B612"
|
|
||||||
id: b612_16
|
|
||||||
size: 16
|
|
||||||
- file: "gfonts://B612"
|
|
||||||
id: b612_12
|
|
||||||
size: 12
|
|
||||||
- file: "gfonts://B612 Mono"
|
|
||||||
id: b612_24_mono
|
|
||||||
size: 24
|
|
||||||
- file: "gfonts://B612 Mono"
|
|
||||||
id: b612_16_mono
|
|
||||||
size: 16
|
|
||||||
- file: "gfonts://B612 Mono"
|
|
||||||
id: b612_12_mono
|
|
||||||
size: 12
|
|
||||||
- file: "gfonts://Roboto"
|
|
||||||
id: roboto_32
|
|
||||||
size: 32
|
|
||||||
- file: "gfonts://Roboto"
|
|
||||||
id: roboto_24
|
|
||||||
size: 24
|
size: 24
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: roboto_16
|
id: font_status
|
||||||
size: 16
|
size: 14
|
||||||
- file: "gfonts://Roboto"
|
|
||||||
id: roboto_12
|
|
||||||
size: 12
|
|
||||||
- file: "gfonts://Roboto Mono"
|
|
||||||
id: roboto_32_mono
|
|
||||||
size: 32
|
|
||||||
- file: "gfonts://Roboto Mono"
|
|
||||||
id: roboto_24_mono
|
|
||||||
size: 24
|
|
||||||
- file: "gfonts://Roboto Mono"
|
|
||||||
id: roboto_16_mono
|
|
||||||
size: 16
|
|
||||||
- file: "gfonts://Roboto Mono"
|
|
||||||
id: roboto_12_mono
|
|
||||||
size: 12
|
|
||||||
|
|
||||||
|
lvgl:
|
||||||
|
log_level: WARN
|
||||||
|
color_depth: 16
|
||||||
|
bg_color: 0x000000
|
||||||
|
text_color: 0xFFFFFF
|
||||||
|
theme:
|
||||||
|
button:
|
||||||
|
bg_color: 0x2B2F36
|
||||||
|
radius: 12
|
||||||
|
checked:
|
||||||
|
bg_color: 0xE08A00
|
||||||
|
buttonmatrix:
|
||||||
|
bg_opa: TRANSP
|
||||||
|
border_width: 0
|
||||||
|
items:
|
||||||
|
bg_color: 0x2B2F36
|
||||||
|
text_color: 0xFFFFFF
|
||||||
|
radius: 12
|
||||||
|
checked:
|
||||||
|
bg_color: 0xE08A00
|
||||||
|
top_layer:
|
||||||
|
widgets:
|
||||||
|
# same navigation bar as on the other LVGL displays
|
||||||
|
- buttonmatrix:
|
||||||
|
id: nav_bar
|
||||||
|
align: bottom_mid
|
||||||
|
width: 100%
|
||||||
|
height: 48
|
||||||
|
pad_all: 4
|
||||||
|
pad_column: 6
|
||||||
|
items:
|
||||||
|
text_font: montserrat_20
|
||||||
|
rows:
|
||||||
|
- buttons:
|
||||||
|
- id: page_prev
|
||||||
|
text: ""
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
lvgl.page.previous:
|
||||||
|
- id: page_home
|
||||||
|
text: ""
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
lvgl.page.show: main_page
|
||||||
|
- id: page_next
|
||||||
|
text: ""
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
lvgl.page.next:
|
||||||
|
pages:
|
||||||
|
# 1: clock
|
||||||
|
- id: main_page
|
||||||
|
widgets:
|
||||||
|
- label:
|
||||||
|
id: clock_label
|
||||||
|
align: CENTER
|
||||||
|
y: -24
|
||||||
|
text_font: font_clock
|
||||||
|
text: "--:--"
|
||||||
|
|
||||||
display:
|
# 2: status (shown until Home Assistant connects)
|
||||||
- platform: mipi_spi
|
- id: status_page
|
||||||
model: ILI9341
|
on_load:
|
||||||
cs_pin: 7
|
- script.execute: update_status
|
||||||
dc_pin: 8
|
widgets:
|
||||||
reset_pin: 10
|
- image:
|
||||||
invert_colors: false
|
src: esphome_logo
|
||||||
lambda: |-
|
align: TOP_MID
|
||||||
it.fill(Color::BLACK);
|
y: 8
|
||||||
|
- label:
|
||||||
// status screen until Home Assistant connects (240x320)
|
align: TOP_MID
|
||||||
if (!api::global_api_server->is_connected_with_state_subscription()) {
|
y: 78
|
||||||
const int cx = it.get_width() / 2;
|
text_font: font_title
|
||||||
it.image(cx, 48, id(esphome_logo), ImageAlign::CENTER);
|
text: "${name}"
|
||||||
it.print(cx, 100, id(roboto_24), id(my_white), TextAlign::CENTER, "${name}");
|
- obj:
|
||||||
|
x: 8
|
||||||
int y = 130;
|
y: 116
|
||||||
if (wifi::global_wifi_component->is_connected()) {
|
width: 224
|
||||||
it.printf(8, y, id(roboto_16), id(status_ok), "WiFi: connected (%d dBm)",
|
height: SIZE_CONTENT
|
||||||
wifi::global_wifi_component->wifi_rssi());
|
bg_opa: TRANSP
|
||||||
y += 22;
|
border_width: 0
|
||||||
// IPv6 first (skip link-local), then IPv4
|
pad_all: 0
|
||||||
char buf[network::IP_ADDRESS_BUFFER_SIZE];
|
scrollbar_mode: "OFF"
|
||||||
for (bool v6 : {true, false}) {
|
layout:
|
||||||
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
|
type: flex
|
||||||
if (!ip.is_set() || ip.is_ip6() != v6 || y > 200)
|
flex_flow: column
|
||||||
continue;
|
pad_row: 6
|
||||||
ip.str_to(buf);
|
widgets:
|
||||||
if (v6 && strncmp(buf, "fe80", 4) == 0)
|
- label:
|
||||||
continue;
|
id: status_wifi
|
||||||
it.print(8, y, id(roboto_12), id(status_dim), buf);
|
text_font: font_status
|
||||||
y += 16;
|
text: ""
|
||||||
}
|
- label:
|
||||||
}
|
id: status_ips
|
||||||
} else {
|
text_font: font_status
|
||||||
it.print(8, y, id(roboto_16), id(status_wait), "WiFi: connecting...");
|
text_color: 0x8C8C8C
|
||||||
y += 22;
|
text: ""
|
||||||
}
|
- label:
|
||||||
y += 8;
|
id: status_ha
|
||||||
it.print(8, y, id(roboto_16), api::global_api_server->is_connected() ? id(status_ok) : id(status_wait),
|
text_font: font_status
|
||||||
api::global_api_server->is_connected() ? "Home Assistant: connected" : "Home Assistant: waiting...");
|
text: ""
|
||||||
|
- label:
|
||||||
it.printf(8, it.get_height() - 8, id(roboto_12), id(status_dim), TextAlign::BOTTOM_LEFT, "ESPHome %s",
|
id: status_footer
|
||||||
ESPHOME_VERSION);
|
text_font: font_status
|
||||||
it.printf(it.get_width() - 8, it.get_height() - 8, id(roboto_12), id(status_dim), TextAlign::BOTTOM_RIGHT,
|
text_color: 0x8C8C8C
|
||||||
"up %us", (unsigned) (millis() / 1000));
|
text: ""
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
it.print(0, 0, id(b612_12), id(my_white), TextAlign::TOP_LEFT, "HELLO WORLD!");
|
|
||||||
it.print(0, 14, id(b612_12), id(my_green), TextAlign::TOP_LEFT, "HELLO WORLD!");
|
|
||||||
it.print(0, 28, id(b612_12), id(my_magenta), TextAlign::TOP_LEFT, "HELLO WORLD!");
|
|
||||||
it.print(0, 42, id(b612_12), id(my_red), TextAlign::TOP_LEFT, "HELLO WORLD!");
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
wifi_ssid: "ssid"
|
wifi_ssid: "ssid"
|
||||||
wifi_password: "password"
|
wifi_password: "password"
|
||||||
|
|
||||||
|
wifi_ssid_ipv6: "ssid"
|
||||||
|
wifi_password_ipv6: "password"
|
||||||
|
|
||||||
api_password: "password"
|
api_password: "password"
|
||||||
api_key: "oSsCFxRUqIJLpT98cQwSwNxYoC7zbUh0YBHczAWRyBI="
|
api_key: "oSsCFxRUqIJLpT98cQwSwNxYoC7zbUh0YBHczAWRyBI="
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user