From 2bbb8f4e114d4bfe78aa3d6841072f53b363791e Mon Sep 17 00:00:00 2001
From: Max Gramser
Date: Wed, 16 Sep 2026 23:20:51 +0200
Subject: [PATCH] Give the history card axes, ranges and a finger readout
(0.2.59)
A sensor, number, binary sensor, person or held switch opens a history card:
a line with round axis steps, clock times and the highest and lowest moment
for numbers, a timeline with the time in each state for on/off, zones and
statuses. The screen asks with esphome.screen_history for 1 hour, 24 hours or
a week; the app answers with one compact history message from the recorder's
statistics or the state changes. A finger on the graph reads the value and
time of that moment at the top while the graph stays as it is. The Guition
weather card starts below its round back button, and the README shows the new
cards and the light and weather cards as they look now.
Co-Authored-By: Claude Opus 5
---
README.md | 23 +-
components/smart_display/history_view.h | 184 ++++++++
components/smart_display/runtime_tiles.h | 578 +++++++++++++++++++++--
docs/EASY_SETUP.md | 6 +-
docs/RELEASING.md | 37 ++
docs/TEST_RESULTS_0259.md | 90 ++++
docs/images/cyd-history-door.png | Bin 0 -> 14232 bytes
docs/images/cyd-history-touch.png | Bin 0 -> 20069 bytes
docs/images/cyd-history.png | Bin 0 -> 20954 bytes
docs/images/cyd-weather.png | Bin 19020 -> 18821 bytes
docs/images/guition-history-door.png | Bin 0 -> 24899 bytes
docs/images/guition-history-person.png | Bin 0 -> 24614 bytes
docs/images/guition-history-touch.png | Bin 0 -> 47893 bytes
docs/images/guition-history.png | Bin 12910 -> 48395 bytes
docs/images/guition-light.png | Bin 17769 -> 23222 bytes
docs/images/guition-weather.png | Bin 36603 -> 36397 bytes
guition-4848s040.yaml | 3 +-
home-like-2432s028.yaml | 3 +-
packages/cyd.yaml | 3 +-
packages/guition.yaml | 3 +-
screen_manager/CHANGELOG.md | 12 +
screen_manager/app/core.py | 2 +-
screen_manager/app/history_card.py | 358 ++++++++++++++
screen_manager/app/server.py | 109 ++++-
screen_manager/config.yaml | 2 +-
tests/test_history_card.py | 405 ++++++++++++++++
tests/test_history_view.cpp | 99 ++++
27 files changed, 1857 insertions(+), 60 deletions(-)
create mode 100644 components/smart_display/history_view.h
create mode 100644 docs/TEST_RESULTS_0259.md
create mode 100644 docs/images/cyd-history-door.png
create mode 100644 docs/images/cyd-history-touch.png
create mode 100644 docs/images/cyd-history.png
create mode 100644 docs/images/guition-history-door.png
create mode 100644 docs/images/guition-history-person.png
create mode 100644 docs/images/guition-history-touch.png
create mode 100644 screen_manager/app/history_card.py
create mode 100644 tests/test_history_card.py
create mode 100644 tests/test_history_view.cpp
diff --git a/README.md b/README.md
index e08ade0..0050618 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
the light's capabilities. Open the detailed control with a long touch.
- **More cards:** climate, vacuum, fan, cover, media player, sensors,
select/input_select, number/input_number, switches, scenes, scripts, and
- buttons. A sensor can open a history card for 1, 6, or 24 hours.
+ buttons.
- **Special cards (firmware 0.2.14+):** a **clock** (digital or analog)
as a built-in tile, a **weather forecast** with five days on a
double-width card, a **graph** of the sensor history in the tile,
@@ -89,6 +89,11 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
a tall position slider on which the blind hangs from the top, a tilt slider over slats for
venetian blinds, open, stop, and close, and the battery of a battery-powered blind
(such as Motionblinds). A cover shows only what it supports (app 0.2.58 / firmware 0.2.50).
+- **History card** for sensors, numbers, binary sensors, people, and switches, the way Home
+ Assistant shows history: a line with an axis in round steps and clock times for numbers,
+ with the highest and lowest moment, and a timeline with the time in each state for on/off,
+ home and away, and a status. Choose **1 hour**, **24 hours**, or **1 week**; hold a finger on
+ the graph to read the value and time of that moment at the top (app 0.2.59 / firmware 0.2.51).
- **Top bar per screen** (firmware 0.2.32+): the name on the left, up to six
items of your choice on the right: the time, an analog clock, the date, or an
entity from Home Assistant with an icon, such as temperature, humidity, power usage, a door
@@ -120,7 +125,7 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
-
+
@@ -128,17 +133,27 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
+
+
+
+
+
+
+
+
+
+
Features depend on the capabilities Home Assistant reports for an entity.
The app must keep running to keep the screens supplied with current data.
-From firmware **0.2.12**, a long press on a switch shows a large
-toggle. A short tap switches immediately; the off state gets a gray icon.
+A short tap on a switch switches immediately; the off state gets a gray icon. A long press
+opens its history card with the toggle at the top right (firmware 0.2.51+).
The feedback stops as soon as Home Assistant reports the changed state, with a
minimum of 150 ms for switches. Tiles with a mini-slider keep their
icon; on the CYD, the icon and text block are vertically centered.
diff --git a/components/smart_display/history_view.h b/components/smart_display/history_view.h
new file mode 100644
index 0000000..619dc09
--- /dev/null
+++ b/components/smart_display/history_view.h
@@ -0,0 +1,184 @@
+#pragma once
+// History on a detail card (firmware 0.2.51+): the pure parts. What a time, a duration or a value reads as, the
+// smooth line through the averages, and which part of the line or which run of the timeline lies under a finger.
+// The drawing is in runtime_tiles.h; tests/test_history_view.cpp covers this.
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace history_view {
+constexpr int PARTS = 24;
+
+// A run of the timeline: from `slot` to the next run's slot it shows `state` (an index into the legend, -1 without
+// data). `begin`, `end` (seconds after the start of the range) and `seconds` are the real times of that state in it.
+struct Run {
+ uint16_t slot = 0;
+ int8_t state = -1;
+ uint32_t begin = 0, end = 0, seconds = 0;
+};
+
+// A clock time of a unix time in the screen's time zone (`offset` seconds east of UTC), as the screen's 12- or
+// 24-hour setting writes it; `weekday` puts the day in front ("Tue 14:05") for a week.
+inline std::string clock(int64_t epoch, int32_t offset, bool h24, bool weekday) {
+ int64_t local = epoch + offset;
+ int64_t days = local >= 0 ? local / 86400 : (local - 86399) / 86400;
+ int seconds = static_cast(local - days * 86400);
+ int hour = seconds / 3600, minute = seconds % 3600 / 60;
+ static const char *const names[] = {"Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"};
+ char b[24];
+ if (h24) snprintf(b, sizeof(b), "%02d:%02d", hour, minute);
+ else snprintf(b, sizeof(b), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM");
+ if (!weekday) return b;
+ return std::string(names[((days % 7) + 7) % 7]) + " " + b;
+}
+
+// A time under a graph: "06:00" on a 24-hour clock, "6 AM" or "6:15 AM" on a 12-hour one.
+inline std::string axis_clock(int64_t epoch, int32_t offset, bool h24) {
+ std::string text = clock(epoch, offset, h24, false);
+ if (h24) return text;
+ auto colon = text.find(':');
+ if (colon != std::string::npos && text.compare(colon, 3, ":00") == 0) text.erase(colon, 3);
+ return text;
+}
+
+// "45 s", "12 min", "3 h 20 min", "2 d 4 h": the time spent in a state.
+inline std::string duration(uint32_t seconds) {
+ char b[32];
+ if (seconds < 60) snprintf(b, sizeof(b), "%u s", static_cast(seconds));
+ else if (seconds < 3600) snprintf(b, sizeof(b), "%u min", static_cast(seconds / 60));
+ else if (seconds < 86400) {
+ unsigned minutes = seconds / 60 % 60;
+ if (minutes) snprintf(b, sizeof(b), "%u h %u min", static_cast(seconds / 3600), minutes);
+ else snprintf(b, sizeof(b), "%u h", static_cast(seconds / 3600));
+ } else {
+ unsigned hours = seconds / 3600 % 24;
+ if (hours) snprintf(b, sizeof(b), "%u d %u h", static_cast(seconds / 86400), hours);
+ else snprintf(b, sizeof(b), "%u d", static_cast(seconds / 86400));
+ }
+ return b;
+}
+
+// A value as Home Assistant writes it: fixed decimals, commas between thousands, "%" and "°" on the number and
+// any other unit after a space.
+inline std::string number(float value, int decimals, const std::string &unit) {
+ if (!std::isfinite(value)) return "--";
+ char b[40];
+ snprintf(b, sizeof(b), "%.*f", std::clamp(decimals, 0, 4), value);
+ std::string text = b, sign;
+ if (text[0] == '-') { sign = "-"; text.erase(0, 1); }
+ auto dot = text.find('.');
+ std::string whole = text.substr(0, dot), rest = dot == std::string::npos ? "" : text.substr(dot);
+ for (int i = static_cast(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast(i), ",");
+ bool zero = whole.find_first_not_of("0,") == std::string::npos && rest.find_first_not_of(".0") == std::string::npos;
+ text = (zero ? "" : sign) + whole + rest;
+ if (unit.empty()) return text;
+ if (unit == "%" || unit == "°") return text + unit;
+ return text + " " + unit;
+}
+
+// Where part `i` of 24 is drawn and read: the middle of its stretch of the range, as a fraction of the width.
+inline float part_middle(int i) { return (static_cast(i) + 0.5f) / PARTS; }
+
+// The part under a finger at `fraction` of the width. It can be a part without a value (a sensor that did not
+// exist yet, a time it was unavailable): the card then says so instead of reading a value from another hour.
+inline int part_at(float fraction) {
+ return std::clamp(static_cast(std::floor(std::clamp(fraction, 0.0f, 1.0f) * PARTS)), 0, PARTS - 1);
+}
+
+// The run of the timeline under a finger: its index into `runs`, or -1.
+inline int run_at(const std::vector &runs, uint16_t slots, float fraction) {
+ if (runs.empty() || !slots) return -1;
+ int slot = std::clamp(static_cast(std::floor(std::clamp(fraction, 0.0f, 1.0f) * slots)), 0, slots - 1);
+ int found = -1;
+ for (size_t i = 0; i < runs.size(); ++i) {
+ if (runs[i].slot <= slot) found = static_cast(i);
+ else break;
+ }
+ return found;
+}
+
+// The slot where run `i` ends (the next run's start, or the end of the timeline).
+inline uint16_t run_end(const std::vector &runs, uint16_t slots, size_t i) {
+ return i + 1 < runs.size() ? runs[i + 1].slot : slots;
+}
+
+// Puts a moment (x, y) on a line through points with rising x: the nearest point within `snap` (the same part of
+// the range) takes its y, unless it is `keep` (a point that already holds a moment); otherwise the moment is added
+// in its place. Returns the moment's index, or -1 when the points are full.
+inline int through(float *xs, float *ys, unsigned &n, unsigned capacity, float x, float y, float snap, int keep = -1) {
+ int nearest = -1;
+ for (unsigned i = 0; i < n; ++i) {
+ const float distance = std::fabs(xs[i] - x);
+ if (static_cast(i) != keep && distance <= snap && (nearest < 0 || distance < std::fabs(xs[nearest] - x))) nearest = static_cast(i);
+ }
+ if (nearest >= 0) {
+ ys[nearest] = y;
+ return nearest;
+ }
+ if (n >= capacity) return -1;
+ unsigned at = n;
+ while (at > 0 && xs[at - 1] > x) {
+ xs[at] = xs[at - 1];
+ ys[at] = ys[at - 1];
+ --at;
+ }
+ xs[at] = x;
+ ys[at] = y;
+ ++n;
+ return static_cast(at);
+}
+
+// A smooth line through points with rising x that never overshoots them (monotone cubic, Fritsch-Carlson): between
+// two points it stays between their values, so a step in a setpoint does not dip below its lowest value and the
+// highest and lowest moment stay on the line. Writes `steps` points per stretch and the last point into `out`
+// (anything with x and y), at most `capacity`; returns how many.
+template
+inline unsigned monotone(const float *xs, const float *ys, unsigned n, unsigned steps, Point *out, unsigned capacity) {
+ using Value = decltype(out[0].x);
+ auto put = [&](unsigned at, float x, float y) {
+ out[at].x = static_cast(std::lround(x));
+ out[at].y = static_cast(std::lround(y));
+ };
+ if (!capacity) return 0;
+ if (n < 2 || steps == 0) {
+ unsigned count = std::min(n, capacity);
+ for (unsigned i = 0; i < count; ++i) put(i, xs[i], ys[i]);
+ return count;
+ }
+ constexpr unsigned MOST = 64;
+ n = std::min(n, MOST);
+ float slope[MOST], tangent[MOST];
+ for (unsigned i = 0; i + 1 < n; ++i) {
+ const float dx = xs[i + 1] - xs[i];
+ slope[i] = dx > 0 ? (ys[i + 1] - ys[i]) / dx : 0.0f;
+ }
+ tangent[0] = slope[0];
+ tangent[n - 1] = slope[n - 2];
+ for (unsigned i = 1; i + 1 < n; ++i)
+ tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0.0f : (slope[i - 1] + slope[i]) / 2;
+ for (unsigned i = 0; i + 1 < n; ++i) {
+ if (slope[i] == 0) { tangent[i] = tangent[i + 1] = 0; continue; }
+ const float a = tangent[i] / slope[i], b = tangent[i + 1] / slope[i], sum = a * a + b * b;
+ if (sum > 9) {
+ const float scale = 3 / std::sqrt(sum);
+ tangent[i] = scale * a * slope[i];
+ tangent[i + 1] = scale * b * slope[i];
+ }
+ }
+ unsigned count = 0;
+ for (unsigned i = 0; i + 1 < n && count + 1 < capacity; ++i) {
+ const float h = xs[i + 1] - xs[i];
+ for (unsigned s = 0; s < steps && count + 1 < capacity; ++s) {
+ const float t = static_cast(s) / steps, t2 = t * t, t3 = t2 * t;
+ const float y = (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * h * tangent[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * h * tangent[i + 1];
+ put(count++, xs[i] + t * h, y);
+ }
+ }
+ put(count++, xs[n - 1], ys[n - 1]);
+ return count;
+}
+} // namespace history_view
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index a84eb7e..e998971 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -9,6 +9,7 @@
#include "cyd_ui.h"
#include "light_controls.h"
#include "tile_controls.h"
+#include "history_view.h"
#include "swipe_profile.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
@@ -40,6 +41,8 @@ inline const lv_font_t *watch_value_font = nullptr, *watch_icon_font = nullptr;
inline const lv_font_t *clock_font = nullptr;
// Text in the -/+ pill and the run key of direct controls; the board profile sets it.
inline const lv_font_t *control_font = nullptr;
+// The smallest regular text (sublabel): axis labels and the legend of the history card.
+inline const lv_font_t *small_font = nullptr;
inline lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously
// Top bar (0.2.32+): the profile's clock label only lends its place and margin; values use the
// profile's text font, icons its small icon font. Set by the board profile at boot.
@@ -65,6 +68,8 @@ inline const char *weather_icon(const std::string &condition);
inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch);
+inline void history_received();
+inline uint32_t domain_accent(const Tile &t);
inline const char *icon_for(const Tile &tile);
inline void label(lv_obj_t *obj, const std::string &text);
inline int active_index = -1;
@@ -75,6 +80,32 @@ inline uint32_t keepalive_seconds = 120;
// Revision of the layout the manager sent last (app 0.2.39+). A keepalive ping carries the
// manager's revision; a mismatch (a restart, a demo layout) asks for the whole layout again.
inline std::string layout_rev;
+// History on a detail card (firmware 0.2.51+): the last history the manager sent (app 0.2.59+ answers
+// history_request with op "history"), for the card that asked.
+struct HistoryState { std::string label; uint32_t color = 0; uint32_t seconds = 0; };
+struct History {
+ std::string entity, unit;
+ uint32_t hours = 0, start = 0, end = 0, began = 0;
+ int32_t offset = 0;
+ bool line = true, has_high = false, has_low = false;
+ float values[history_view::PARTS]{};
+ bool has[history_view::PARTS]{};
+ float bottom = 0, top = 1, high = 0, low = 0;
+ uint32_t high_at = 0, low_at = 0;
+ int decimals = 1, active = -1;
+ std::vector> ticks;
+ std::vector times;
+ uint16_t slots = 0;
+ std::vector runs;
+ std::vector states;
+ // Home Assistant's words for the states (raw state, words), so the heading follows a state that changes.
+ std::vector> words;
+};
+inline History history;
+// The range the open card shows (1, 24 or 168 hours); what it asked for last and when; whether that answer came.
+inline uint32_t history_hours = 24, history_asked_at = 0, history_asked_hours = 0, history_received_at = 0;
+inline std::string history_asked_entity;
+inline bool history_answered = false;
inline std::function layout_changed, refresh, dismiss, settings_changed;
inline esphome::ESPPreferenceObject settings_preference;
// The two extra values of 0.2.44+ in one record: going back to page 1 by itself, and after how long.
@@ -302,6 +333,90 @@ inline std::string receive(const std::string &payload) {
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
+ if (op == "history") {
+ // A detail card's history (app 0.2.59+), the answer to history_request. Checked whole before it replaces
+ // the one the screen holds.
+ History next;
+ next.entity = string(root["entity"], 120);
+ next.hours = root["hours"] | 0u;
+ if (!valid_entity(next.entity) || (next.hours != 1 && next.hours != 24 && next.hours != 168)) return false;
+ // An answer to an earlier question (another card, another range) would push out the one the card waits for.
+ if (next.entity != history_asked_entity || next.hours != history_asked_hours) {
+ result = model.ready() ? "Synced" : "Loading tiles";
+ return true;
+ }
+ next.start = root["start"] | 0u;
+ next.end = root["end"] | 0u;
+ next.offset = std::clamp(root["off"] | 0, -14 * 3600, 14 * 3600);
+ if (next.end <= next.start) return false;
+ next.line = string(root["kind"], 12) != "timeline";
+ if (root["xt"].is()) for (JsonVariant moment : root["xt"].as()) {
+ if (next.times.size() == 8) break;
+ if (moment.is()) next.times.push_back(moment.as());
+ }
+ if (next.line) {
+ unsigned i = 0;
+ if (root["values"].is()) for (JsonVariant value : root["values"].as()) {
+ if (i == history_view::PARTS) break;
+ float v = number(value);
+ next.has[i] = std::isfinite(v);
+ next.values[i] = next.has[i] ? v : 0;
+ ++i;
+ }
+ next.bottom = number(root["dom"][0], 0);
+ next.top = number(root["dom"][1], 1);
+ if (!(next.top > next.bottom)) next.top = next.bottom + 1;
+ if (root["yt"].is()) for (JsonVariant tick : root["yt"].as()) {
+ if (next.ticks.size() == 6) break;
+ float v = number(tick[0]);
+ if (std::isfinite(v)) next.ticks.emplace_back(v, string(tick[1], 16));
+ }
+ next.high = number(root["hi"][0]);
+ next.has_high = std::isfinite(next.high);
+ next.high_at = root["hi"][1] | 0u;
+ next.low = number(root["lo"][0]);
+ next.has_low = std::isfinite(next.low);
+ next.low_at = root["lo"][1] | 0u;
+ next.decimals = std::clamp(root["dec"] | 1, 0, 4);
+ next.unit = string(root["unit"], 16);
+ } else {
+ next.slots = static_cast(std::clamp(root["slots"] | 96u, 1u, 96u));
+ if (root["states"].is()) for (JsonVariant state : root["states"].as()) {
+ if (next.states.size() == 7) break;
+ HistoryState s;
+ s.label = string(state[0], 24);
+ s.color = std::strtoul(string(state[1], 8).c_str(), nullptr, 16);
+ s.seconds = state[2] | 0u;
+ next.states.push_back(std::move(s));
+ }
+ if (root["seg"].is()) for (JsonVariant item : root["seg"].as()) {
+ if (next.runs.size() == 96) break;
+ const unsigned slot = item[0] | 0u;
+ const int state = item[1] | -1;
+ if (slot >= next.slots || state >= static_cast(next.states.size()) || state < -1) return false;
+ history_view::Run run;
+ run.slot = static_cast(slot);
+ run.state = static_cast(state);
+ run.begin = item[2] | 0u;
+ run.end = std::max(run.begin, item[3] | 0u);
+ run.seconds = item[4] | 0u;
+ next.runs.push_back(run);
+ }
+ if (root["words"].is()) for (JsonVariant pair : root["words"].as()) {
+ if (next.words.size() == 12) break;
+ next.words.emplace_back(string(pair[0], 32), string(pair[1], 24));
+ }
+ next.began = root["began"] | 0u;
+ next.active = root["active"] | -1;
+ if (next.active >= static_cast(next.states.size())) next.active = -1;
+ }
+ history = std::move(next);
+ history_received_at = esphome::millis();
+ history_answered = true;
+ history_received();
+ result = model.ready() ? "Synced" : "Loading tiles";
+ return true;
+ }
#ifdef SWIPE_PROFILE
if (op == "swipe_test") {
// Diagnostic builds only: page switches without a finger, `n` of them `ms` apart; `back`
@@ -465,6 +580,29 @@ inline void action(const std::string &service, const std::string &entity, const
esphome::api::global_api_server->send_homeassistant_action(request);
ESP_LOGI("runtime_action","Sent service=%s entity=%s",service.c_str(),entity.c_str());
}
+// A detail card asks the manager for its history (app 0.2.59+ answers with op "history"). An event, like
+// setting_event: it needs no permission to call Home Assistant actions.
+inline void history_request(const std::string &entity, uint32_t hours) {
+ if (inbox.empty()) return;
+ esphome::api::HomeassistantActionRequest request;
+ request.service = esphome::StringRef("esphome.screen_history");
+ request.is_event = true;
+ const std::string span = std::to_string(hours);
+ const std::string keys[] = {"inbox", "entity", "hours"}, values[] = {inbox, entity, span};
+ request.data.init(3);
+ for (int i = 0; i < 3; ++i) {
+ esphome::api::HomeassistantServiceMap entry;
+ entry.key = esphome::StringRef(keys[i]);
+ entry.value = esphome::StringRef(values[i]);
+ request.data.push_back(entry);
+ }
+ esphome::api::global_api_server->send_homeassistant_action(request);
+ history_asked_at = esphome::millis();
+ history_answered = false;
+ history_asked_entity = entity;
+ history_asked_hours = hours;
+ ESP_LOGI("history", "asked for %u h of %s", static_cast(hours), entity.c_str());
+}
inline void setting_event(const std::string &key, int value) {
if(inbox.empty())return;
esphome::api::HomeassistantActionRequest request;request.service=esphome::StringRef("esphome.screen_setting");request.is_event=true;
@@ -573,6 +711,15 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
+ // History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
+ if(cmd>=160&&cmd<163){
+ static const uint32_t hours[]={1,24,168};
+ if(detail_index=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
@@ -646,7 +793,9 @@ inline void render_weather_detail(const Tile &t,bool large,int width,int height,
unsigned columns=std::min(weather.hours.size(),6);
int hours_h=columns?small_h+mini_h+text_h+small_h+(large?16:6):0;
int card_a_h=card_pad+hero+(columns?(large?14:8)+hours_h:0)+card_pad;
- int y=large?62:38;
+ // The Guition starts below the round back button of the top bar (60 px at 16); the CYD needs every pixel for the
+ // coming days and starts where it did.
+ int y=large?84:38;
auto *now=detail_card(pad,y,width-2*pad,card_a_h);
// Now: icon, temperature, condition, then feels-like / humidity / wind in one muted line.
int cy=card_pad;char b[48];
@@ -1038,32 +1187,320 @@ inline void render_cover_detail(Tile &t,bool large,int width,int height,int pad)
if(key_count){cover_key_row(keys,key_count,pad,y,inner,key_h,gap,key_icons);y+=key_h+gap;}
if(tilt_count)cover_key_row(tilt_keys,tilt_count,pad,y,inner,key_h,gap,key_icons);
}
-inline void show_detail(unsigned index){
- if(index>=model.count)return;detail_index=index;auto &t=model.tiles[index];
- if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
- if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
- detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
- lv_obj_set_style_bg_color(detail_root,lv_color_hex(0xE7E7E7),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
- int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
- bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
- // The same top bar as the board's own cards: a round back arrow at the left, the name centred.
- int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8;
- auto *back=detail_button("",bar_x,bar_y,bar,bar,-1);lv_obj_set_style_radius(back,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(back,lv_color_hex(0xEEEEEE),0);
- auto *arrow=lv_obj_get_child(back,0);if(mini_icon_font)lv_obj_set_style_text_font(arrow,mini_icon_font,0);lv_label_set_text(arrow,"\U000F004D");lv_obj_set_size(arrow,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(arrow);
- const lv_font_t *title_font=watch_font?watch_font:detail_font;
- auto *heading=detail_label(detail_root,t.name,bar_x+bar+8,bar_y+(bar-lv_font_get_line_height(title_font))/2,width-2*(bar_x+bar+8));
- lv_obj_set_style_text_font(heading,title_font,0);lv_obj_set_height(heading,lv_font_get_line_height(title_font));lv_obj_set_style_text_align(heading,LV_TEXT_ALIGN_CENTER,0);
+// ---- History card (firmware 0.2.51+): numbers as a line with axes, states as a timeline ----
+// Sensors, numbers, switches, binary sensors and people. The card asks the manager for the chosen range when it
+// opens (an hour, a day or a week; always 24 averages or 96 slots) and draws what comes back. A finger on the
+// graph shows the value or state and its time at the top, until it lifts.
+inline bool history_card(const Tile &t){
auto d=t.domain();
- std::string state=d=="cover"?cover_status_line(t):detail_state(t);
- // The vacuum card draws its own state (hero or status row) with the battery beside it.
- if(d!="vacuum"){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,lv_color_hex(0x616161),0);}
+ return d=="sensor"||d=="binary_sensor"||d=="switch"||d=="input_boolean"||d=="person"||d=="number"||d=="input_number";
+}
+// Before the history arrives: a line for numbers, a timeline for the rest.
+inline bool history_line_guess(const Tile &t){
+ auto d=t.domain();
+ if(d=="number"||d=="input_number")return true;
+ if(d!="sensor"||t.device_class=="enum"||t.device_class=="timestamp"||t.device_class=="date")return false;
+ if(!t.unit.empty())return true;
+ char *end=nullptr;std::strtof(t.state.c_str(),&end);return end!=t.state.c_str();
+}
+// The open card's parts that a finger changes, and the plot: x, y, w, h on the screen.
+struct HistoryChart {
+ lv_obj_t *value=nullptr,*first=nullptr,*second=nullptr,*area=nullptr,*status=nullptr;
+ int x=0,y=0,w=0,h=0;
+ float bottom=0,top=1;
+ // The highest and lowest moment the card shows: the history's, or the value now when it goes beyond them.
+ float high=NAN,low=NAN;
+ uint32_t high_at=0,low_at=0;
+ bool line=true,ready=false;
+ uint32_t accent=0x2196F3;
+ std::string value_text,first_text,second_text;
+ lv_point_precise_t *points=nullptr;unsigned count=0;
+};
+inline HistoryChart history_chart;
+inline void history_forget(){
+ auto &c=history_chart;c.value=c.first=c.second=c.area=c.status=nullptr;c.count=0;c.ready=false;c.high=c.low=NAN;
+}
+inline float history_y(float value){
+ const auto &c=history_chart;
+ return c.y+c.h-(std::clamp(value,c.bottom,c.top)-c.bottom)/(c.top-c.bottom)*c.h;
+}
+// Where a moment of the range lies across the plot, from its left edge.
+inline float history_x(uint32_t at){
+ const auto &h=history;const auto &c=history_chart;
+ return float(std::clamp(at,h.start,h.end)-h.start)/float(std::max(1,h.end-h.start))*(c.w-1);
+}
+// The history holds the open card's entity and range, and is new: the answer to this opening, or less than a
+// minute old (a card opened again shows it at once while it asks).
+inline bool history_fits(const Tile &t){
+ return history.entity==t.entity&&history.hours==history_hours&&
+ (history_answered||esphome::millis()-history_received_at<60000);
+}
+// Home Assistant's word for the state now: from the history's words, else the card's own.
+inline std::string history_words(const Tile &t){
+ if(!t.available())return "Unavailable";
+ for(const auto &pair:history.words)if(pair.first==t.state)return pair.second;
+ return detail_state(t);
+}
+inline std::string history_clock(uint32_t epoch,bool weekday){
+ return history_view::clock(epoch,history.offset,screen_settings::current.clock_24h!=0,weekday);
+}
+// The soft area under the line: two triangles per segment down to the plot's bottom, no layer buffer.
+inline void history_fill(lv_event_t *e){
+ const auto &c=history_chart;if(!c.line||c.count<2||!c.area)return;
+ auto *layer=lv_event_get_layer(e);lv_area_t area;lv_obj_get_coords(c.area,&area);
+ lv_draw_triangle_dsc_t dsc;lv_draw_triangle_dsc_init(&dsc);dsc.color=lv_color_hex(c.accent);dsc.opa=LV_OPA_20;
+ const lv_value_precise_t ox=area.x1,oy=area.y1,base=area.y2+1;
+ for(unsigned i=0;i+1=static_cast(h.states.size())?0xF1F1F1:h.states[state].color);
+ lv_area_t piece{x1,area.y1,std::max(x1,x2),area.y2};lv_draw_rect(layer,&dsc,&piece);
+ }
+}
+inline void history_restore(){
+ auto &c=history_chart;
+ if(c.value){label(c.value,c.value_text);lv_obj_set_style_text_color(c.value,lv_color_hex(0x1B1B1B),0);}
+ if(c.first)label(c.first,c.first_text);
+ if(c.second)label(c.second,c.second_text);
+}
+// A finger on the graph: the value (or state) of the moment under it, and when, at the top of the card. The graph
+// itself stays as it is.
+inline void history_scrub(lv_event_t *e){
+ auto &c=history_chart;const auto &h=history;auto code=lv_event_get_code(e);
+ if(code==LV_EVENT_RELEASED||code==LV_EVENT_PRESS_LOST){history_restore();return;}
+ if((code!=LV_EVENT_PRESSED&&code!=LV_EVENT_PRESSING)||!c.ready||!c.value||!lv_indev_active())return;
+ lv_point_t point;lv_indev_get_point(lv_indev_active(),&point);
+ const float fraction=std::clamp(float(point.x-c.x)/float(std::max(1,c.w)),0.0f,1.0f);
+ const bool week=h.hours==168;
+ if(c.line){
+ // A part without a value (before the sensor existed, while it was unavailable) says so.
+ const int i=history_view::part_at(fraction);
+ const uint64_t span=h.end-h.start;
+ const uint32_t begin=h.start+static_cast(span*i/history_view::PARTS),finish=h.start+static_cast(span*(i+1)/history_view::PARTS);
+ label(c.value,h.has[i]?history_view::number(h.values[i],h.decimals,h.unit):"No data");
+ lv_obj_set_style_text_color(c.value,lv_color_hex(h.has[i]?c.accent:0x6B6B6B),0);
+ if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,false));
+ if(c.second)label(c.second,!h.has[i]?"":h.hours==1?"average":"average of "+history_view::duration(finish-begin));
+ }else{
+ // A run reads the real times of its state, not the slots it covers: a door open for 5 minutes says 5 min.
+ const int r=history_view::run_at(h.runs,h.slots,fraction);if(r<0)return;
+ const auto &run=h.runs[r];
+ const uint32_t begin=h.start+run.begin,finish=h.start+run.end;
+ label(c.value,run.state<0||run.state>=static_cast(h.states.size())?"No data":h.states[run.state].label);
+ if(c.first)label(c.first,history_clock(begin,week)+" \u2013 "+history_clock(finish,week&&finish-begin>=43200));
+ if(c.second)label(c.second,history_view::duration(run.seconds));
+ }
+}
+// The transparent area a finger scrubs, over the graph and a little around it; it keeps the finger while it drags.
+inline void history_touch(int x,int y,int w,int h){
+ auto *area=lv_obj_create(detail_root);lv_obj_remove_style_all(area);lv_obj_set_pos(area,x,y);lv_obj_set_size(area,w,h);
+ lv_obj_add_flag(area,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(area,LV_OBJ_FLAG_PRESS_LOCK);
+ lv_obj_remove_flag(area,LV_OBJ_FLAG_SCROLLABLE);lv_obj_remove_flag(area,LV_OBJ_FLAG_GESTURE_BUBBLE);
+ lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr);
+}
+// Times below the graph: round clock times (weekdays for a week) where they fit, and "Now" at the end.
+inline void history_times(int x,int w,int y,const lv_font_t *font,bool large){
+ const auto &h=history;
+ const lv_font_t *bold=detail_font?detail_font:font;
+ const int now_w=text_width("Now",bold),gap=large?10:6;
+ detail_text(detail_root,"Now",x+w-now_w,y,now_w+2,bold,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
+ int last=x-1000;
+ for(uint32_t at:h.times){
+ if(at<=h.start||at>=h.end)continue;
+ const std::string words=h.hours==168?history_view::clock(at,h.offset,true,true).substr(0,3)
+ :history_view::axis_clock(at,h.offset,screen_settings::current.clock_24h!=0);
+ const int tw=text_width(words,font),cx=x+static_cast(std::lround(float(at-h.start)/float(h.end-h.start)*w));
+ const int left=std::max(x-(large?8:4),cx-tw/2);
+ if(left<=last+gap||left+tw>x+w-now_w-gap)continue;
+ detail_shape(detail_root,cx,y-(large?7:4),1,large?5:3,0xCCCCCC,0);
+ detail_text(detail_root,words,left,y,tw+2,font,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
+ last=left+tw;
+ }
+}
+inline void render_history_line(bool large,int card_x,int card_y,int card_w,int card_h,const lv_font_t *small,float current){
+ auto &c=history_chart;const auto &h=history;
+ // The value now can lie outside the history (the last hour is not in the statistics yet): the plot makes room.
+ c.bottom=h.bottom;c.top=h.top;
+ if(std::isfinite(current)&&(currentc.top)){
+ const float margin=(std::max(c.top,current)-std::min(c.bottom,current))*0.06f;
+ if(current(std::lround(history_y(tick.first)));
+ detail_shape(detail_root,c.x,ty,c.w,1,0xEEEEEE,0);
+ detail_text(detail_root,tick.second,card_x+edge,ty-label_h/2,label_w+2,small,LV_TEXT_ALIGN_RIGHT,0x6B6B6B);
+ }
+ // Through the middle of each part, from the left edge when the range begins with a value, to "Now" at the right
+ // (the value now); a curve that never overshoots them, so the axis and the highest and lowest moment stay true.
+ // The averages only reach the highest and lowest moment in their hour, so the line also runs through those two
+ // moments: its peak and its valley are where the rings are.
+ float xs[history_view::PARTS+4],ys[history_view::PARTS+4];unsigned n=0;
+ for(int i=0;i=0)high_x=xs[kept];
+ if(std::isfinite(c.low)){
+ const int at=history_view::through(xs,ys,n,history_view::PARTS+4,low_x,history_y(c.low)-c.y,snap,kept);
+ if(at>=0)low_x=xs[at];
+ }
+ }
+ if(!c.points)c.points=new lv_point_precise_t[POINT_BUFFER];
+ c.count=n>=2?history_view::monotone(xs,ys,n,4,c.points,POINT_BUFFER):0;
+ c.area=lv_obj_create(detail_root);lv_obj_remove_style_all(c.area);lv_obj_set_pos(c.area,c.x,c.y);lv_obj_set_size(c.area,c.w,c.h);
+ lv_obj_remove_flag(c.area,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(c.area,LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_event_cb(c.area,history_fill,LV_EVENT_DRAW_MAIN,nullptr);
+ if(c.count>=2){
+ auto *stroke=lv_line_create(detail_root);lv_obj_remove_flag(stroke,LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_line_rounded(stroke,true,0);lv_obj_set_style_line_width(stroke,large?3:2,0);
+ lv_obj_set_style_line_color(stroke,lv_color_hex(c.accent),0);
+ lv_line_set_points(stroke,c.points,c.count);lv_obj_set_pos(stroke,c.x,c.y);
+ }
+ // The highest and lowest moment as rings; the Guition writes their values beside them.
+ const int ring=large?12:8;
+ auto marker=[&](float value,float at_x,bool high){
+ const int mx=c.x+static_cast(std::lround(at_x));
+ const int my=static_cast(std::lround(history_y(value)));
+ auto *o=detail_shape(detail_root,mx-ring/2,my-ring/2,ring,ring,0xFFFFFF,ring/2);
+ lv_obj_set_style_border_width(o,large?3:2,0);lv_obj_set_style_border_color(o,lv_color_hex(c.accent),0);
+ if(!large)return;
+ const std::string words=history_view::number(value,h.decimals,"");
+ const int lh=lv_font_get_line_height(detail_font),tw=text_width(words,detail_font)+2;
+ int ly=high?my-ring/2-lh-2:my+ring/2+2;
+ if(lyc.y+c.h+6)ly=my-ring/2-lh-2;
+ const int lx=std::max(c.x,std::min(mx-tw/2,c.x+c.w-tw));
+ detail_text(detail_root,words,lx,ly,tw,detail_font,LV_TEXT_ALIGN_CENTER,0x1B1B1B);
+ };
+ if(std::isfinite(c.high))marker(c.high,high_x,true);
+ if(std::isfinite(c.low)&&c.low!=c.high)marker(c.low,low_x,false);
+ if(std::isfinite(current)){
+ const int size=large?12:8;
+ auto *o=detail_shape(detail_root,c.x+c.w-1-size/2,static_cast(std::lround(history_y(current)))-size/2,size,size,c.accent,size/2);
+ lv_obj_set_style_border_width(o,2,0);lv_obj_set_style_border_color(o,lv_color_hex(0xFFFFFF),0);
+ }
+ history_times(c.x,c.w,c.y+c.h+(large?8:4),small,large);
+ history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),card_h);
+}
+inline void render_history_timeline(bool large,int card_x,int card_y,int card_w,int card_h,const lv_font_t *small){
+ auto &c=history_chart;const auto &h=history;
+ const int edge=large?16:8,label_h=lv_font_get_line_height(small),times_gap=large?8:4,legend_gap=large?16:6;
+ const int row_h=label_h+(large?10:3),square=large?14:8;
+ c.x=card_x+edge;c.w=card_w-2*edge;c.h=large?56:24;
+ // Bar, times and legend as one block in the middle of the card; a legend that does not fit loses its last rows.
+ const int above=c.h+times_gap+label_h+legend_gap;
+ const int rows=std::max(1,std::min(static_cast((h.states.size()+1)/2),(card_h-2*edge-above+row_h-label_h)/row_h));
+ const int block=above+rows*row_h-(row_h-label_h);
+ c.y=card_y+std::max(edge,(card_h-block)/2);
+ auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,c.x,c.y);lv_obj_set_size(bar,c.w,c.h);
+ lv_obj_remove_flag(bar,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(bar,LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_event_cb(bar,history_bar,LV_EVENT_DRAW_MAIN,nullptr);
+ history_times(c.x,c.w,c.y+c.h+times_gap,small,large);
+ // Every state with its colour and its time, in two columns.
+ const int top=c.y+above,col_w=c.w/2;
+ const size_t shown=std::min(h.states.size(),static_cast(rows)*2);
+ for(size_t i=0;i(i%2)*col_w,ly=top+static_cast(i/2)*row_h;
+ detail_shape(detail_root,lx,ly+(label_h-square)/2,square,square,h.states[i].color,large?4:2);
+ const std::string time=history_view::duration(h.states[i].seconds);
+ const int tw=text_width(time,small)+2,words_x=lx+square+(large?10:5),time_x=lx+col_w-(large?14:8)-tw;
+ detail_text(detail_root,h.states[i].label,words_x,ly,std::max(8,time_x-words_x-6),small,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
+ detail_text(detail_root,time,time_x,ly,tw,small,LV_TEXT_ALIGN_LEFT,0x6B6B6B);
+ }
+ history_touch(c.x-(large?14:8),card_y,c.w+(large?28:16),c.y+c.h+(large?24:12)-card_y);
+}
+// The range: an hour, a day or a week, as one segmented row. It asks the manager, not Home Assistant, so
+// waiting for a command does not lock it.
+inline void history_ranges(int x,int y,int w,int h){
+ static const uint32_t hours[]={1,24,168};
+ static const char *const words[]={"1 hour","24 hours","1 week"};
+ const lv_font_t *font=control_font?control_font:detail_font;
+ auto *track=detail_shape(detail_root,x,y,w,h,0xFFFFFF,h/2);
+ lv_obj_set_style_border_width(track,1,0);lv_obj_set_style_border_color(track,lv_color_hex(0xDDDDDD),0);
+ const int inset=std::max(3,h/10),sw=(w-2*inset)/3;
+ for(int i=0;i<3;++i){
+ const bool selected=history_hours==hours[i];
+ const int segment_w=i==2?w-2*inset-2*sw:sw;
+ auto *segment=detail_button(words[i],x+inset+i*sw,y+inset,segment_w,h-2*inset,160+i);
+ lv_obj_set_style_radius(segment,(h-2*inset)/2,0);
+ lv_obj_set_style_bg_color(segment,lv_color_hex(0x009FE3),0);
+ lv_obj_set_style_bg_opa(segment,selected?LV_OPA_COVER:LV_OPA_TRANSP,0);
+ lv_obj_set_style_bg_opa(segment,LV_OPA_COVER,LV_STATE_PRESSED);
+ if(!selected)lv_obj_set_style_bg_color(segment,lv_color_hex(0xD5EEFC),LV_STATE_PRESSED);
+ button_words(segment,font,selected?0xFFFFFF:0x1B1B1B,segment_w-6);
+ if(detail_action_count&&detail_actions[detail_action_count-1]==segment)--detail_action_count;
+ }
+}
+inline void render_history_detail(const Tile &t,bool large,int width,int height,int pad){
+ auto &c=history_chart;const auto &h=history;auto d=t.domain();
+ history_forget();
+ // Asked once per opening and range; again after 30 s without an answer (see tick).
+ if(history_asked_entity!=t.entity||history_asked_hours!=history_hours||(!history_fits(t)&&esphome::millis()-history_asked_at>30000))
+ history_request(t.entity,history_hours);
+ c.ready=history_fits(t);
+ const bool line=c.ready?h.line:history_line_guess(t);
+ c.line=line;c.accent=domain_accent(t);
+ const lv_font_t *big=watch_value_font?watch_value_font:(watch_font?watch_font:detail_font);
+ const lv_font_t *text=control_font?control_font:detail_font;
+ const lv_font_t *small=small_font?small_font:detail_font;
+ // The header: the value now (or the state), and at the right what the history says about it.
+ char *end=nullptr;const float parsed=std::strtof(t.state.c_str(),&end);
+ const bool numeric=end!=t.state.c_str()&&std::isfinite(parsed)&&t.available();
+ const float current=numeric?parsed:NAN;
+ c.first_text.clear();c.second_text.clear();
+ if(line){
+ int decimals=0;const auto dot=t.state.find('.');if(dot!=std::string::npos)decimals=static_cast(std::min(4,t.state.size()-dot-1));
+ if(c.ready)decimals=h.decimals;
+ c.value_text=!t.available()?"Unavailable":numeric?history_view::number(current,decimals,t.unit):t.state;
+ if(c.ready){
+ // The value now counts too: it can lie beyond the history (the running hour is not in the statistics yet).
+ if(h.has_high){c.high=h.high;c.high_at=h.high_at;}
+ if(h.has_low){c.low=h.low;c.low_at=h.low_at;}
+ if(std::isfinite(current)&&std::isfinite(c.high)&¤t>c.high){c.high=current;c.high_at=h.end;}
+ if(std::isfinite(current)&&std::isfinite(c.low)&¤t=0){
+ c.first_text=h.states[h.active].label+" \u00B7 "+history_view::duration(h.states[h.active].seconds);
+ c.second_text=h.began==1?"once":h.began?std::to_string(h.began)+" times":"";
+ }
+ }
+ const int row=large?84:50,row_h=lv_font_get_line_height(big),text_h=lv_font_get_line_height(text);
+ int right=width-pad-(large?8:4);
if(t.is_switch()){
- detail_label(detail_root,"Tap to toggle",pad,top,width-2*pad);
+ const int sw=large?84:52,sh=large?46:28;
detail_switch=lv_switch_create(detail_root);
- lv_obj_set_size(detail_switch,large?240:140,large?112:64);
- lv_obj_set_pos(detail_switch,(width-(large?240:140))/2,top+(large?65:30));
+ lv_obj_set_size(detail_switch,sw,sh);lv_obj_set_pos(detail_switch,right-sw,row+(row_h-sh)/2);
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xB0B0B0),LV_PART_MAIN);
- lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFB900),LV_PART_INDICATOR|LV_STATE_CHECKED);
+ lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFB900),static_cast(LV_PART_INDICATOR)|LV_STATE_CHECKED);
lv_obj_set_style_bg_color(detail_switch,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
lv_obj_set_style_opa(detail_switch,LV_OPA_50,LV_STATE_DISABLED);
if(t.state=="on")lv_obj_add_state(detail_switch,LV_STATE_CHECKED);
@@ -1077,31 +1514,80 @@ inline void show_detail(unsigned index){
if(tile.state=="on")lv_obj_add_state(control,LV_STATE_CHECKED);else lv_obj_remove_state(control,LV_STATE_CHECKED);
if(allowed)action(tile.domain()+(requested_on?".turn_on":".turn_off"),tile.entity);
},LV_EVENT_VALUE_CHANGED,nullptr);
- detail_actions[detail_action_count++]=detail_switch;
+ if(detail_action_count<32)detail_actions[detail_action_count++]=detail_switch;
+ right-=sw+(large?14:8);
+ }
+ // The value column fits the widest text a finger can show there, so a readout never runs into the texts beside it.
+ int widest=text_width(c.value_text,big);
+ if(c.ready)widest=std::max(widest,text_width("No data",big));
+ if(c.ready&&line){for(int i=0;i=model.count)return;
+ // A card that opens starts on a day (an hour for a tile whose graph shows one); switching ranges keeps it open.
+ if(!detail_root||lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN)||detail_index!=index){
+ history_hours=model.tiles[index].history_hours==1?1:24;history_asked_entity.clear();
+ }
+ detail_index=index;auto &t=model.tiles[index];
+ if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
+ if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
+ detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
+ lv_obj_set_style_bg_color(detail_root,lv_color_hex(0xE7E7E7),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
+ int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
+ bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
+ // The same top bar as the board's own cards: a round back arrow at the left, the name centred.
+ int bar=large?60:40,bar_x=large?16:10,bar_y=large?16:8;
+ auto *back=detail_button("",bar_x,bar_y,bar,bar,-1);lv_obj_set_style_radius(back,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_color(back,lv_color_hex(0xEEEEEE),0);
+ auto *arrow=lv_obj_get_child(back,0);if(mini_icon_font)lv_obj_set_style_text_font(arrow,mini_icon_font,0);lv_label_set_text(arrow,"\U000F004D");lv_obj_set_size(arrow,LV_SIZE_CONTENT,LV_SIZE_CONTENT);lv_obj_center(arrow);
+ const lv_font_t *title_font=watch_font?watch_font:detail_font;
+ auto *heading=detail_label(detail_root,t.name,bar_x+bar+8,bar_y+(bar-lv_font_get_line_height(title_font))/2,width-2*(bar_x+bar+8));
+ lv_obj_set_style_text_font(heading,title_font,0);lv_obj_set_height(heading,lv_font_get_line_height(title_font));lv_obj_set_style_text_align(heading,LV_TEXT_ALIGN_CENTER,0);
+ auto d=t.domain();
+ std::string state=d=="cover"?cover_status_line(t):detail_state(t);
+ // The vacuum and history cards draw their own state.
+ const bool with_history=history_card(t);
+ if(d!="vacuum"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,lv_color_hex(0x616161),0);}
+ if(with_history){
+ render_history_detail(t,large,width,height,pad);
}else if(d=="vacuum"){
render_vacuum_detail(t,large,width,height,pad);
}else if(d=="cover"){
if(detail_status && control_font){lv_obj_set_style_text_font(detail_status,control_font,0);lv_obj_set_height(detail_status,lv_font_get_line_height(control_font));}
render_cover_detail(t,large,width,height,pad);
- }else if(d=="sensor"){
- float minimum=INFINITY,maximum=-INFINITY;for(float value:t.history)if(t.has_history&&std::isfinite(value)){minimum=std::min(minimum,value);maximum=std::max(maximum,value);}
- if(!std::isfinite(minimum)){detail_label(detail_root,"No numeric HA history",pad,top,width-2*pad);return;}
- char text[100];snprintf(text,sizeof(text),"%u hours / %.2f - %.2f %s",t.history_hours,minimum,maximum,t.unit.c_str());detail_label(detail_root,text,pad,top,width-2*pad);
- int chart_y=top+(large?46:28),chart_h=height-chart_y-30,bar_w=(width-pad*2)/24;
- for(unsigned i=0;iminimum?8+(chart_h-8)*(t.history[i]-minimum)/(maximum-minimum):chart_h/2;
- auto *bar=lv_obj_create(detail_root);lv_obj_remove_style_all(bar);lv_obj_set_pos(bar,pad+i*bar_w,chart_y+chart_h-h);lv_obj_set_size(bar,std::max(2,bar_w-2),h);lv_obj_set_style_bg_color(bar,lv_color_hex(0x16A5E6),0);lv_obj_set_style_bg_opa(bar,LV_OPA_COVER,0);}
- detail_label(detail_root,std::to_string(t.history_hours)+" hours ago",pad,height-24,(width-2*pad)/2);
- auto *now=detail_label(detail_root,"Now",width/2,height-24,width/2-pad);lv_obj_set_style_text_align(now,LV_TEXT_ALIGN_RIGHT,0);
}else if(d=="select"||d=="input_select"){
const auto &options=t.extra().options;
for(unsigned i=0;i30000)history_request(t.entity,history_hours);
+ else if(now-history_asked_at>8000)label(history_chart.status,"No history available");
+ }
for(unsigned i=0;is;4sc|M=7*TG&f(%ZFNbUOe5
zyAb-?rT_rN003lo8xMF!v8{g$0F-SJ+UL#uNaO+k*JjI!s|&t`%4$WrM_%xpIikIH
zRYWIxe_fmjuJ)wvsZsZ46^!9{GnNo{X4=b+Tb=l}HcVwPg!dF-`DwEflDEzbW@w+Rg
zGEp$Zw9&3W>aeEIIt&q&VKg)NfQR0@5Di0QU|W$FKdWJUHo!j|Q;+oiXibJ8Oj}SI
z{b{@3C&Cd?siHI7D+N?(M3k9rBEiOo30^pzDe(8gU~g#PFB*HGTqC4>-^D1{=bEL{
z4Ddusj;WS_79t8ZeFk!tb3*f<6Qw*?z$tmZ=Tlry@L&dMZ{_dN{3zZ?r(*E27ceH)@J?|OtblW~Ff7VKf&6mw?Z4wYizaPdyGa+>!*(oPxXt?>&mK?#Z>qiz7BF;$ue}A`ykiH<
zDVV;J-9+0;U#0MkMQcs$1@AeE0SEoc{VHgeb+Tr#W6Jp>U;4|vq+dx$okT=EVb3Tk
z{S$XycNAVjRB5^55>$%)81SAHaejh`qE%+2?R9<;nFJM8i+>*Fcn*?P;?Fc-RFt|6
z&D{W(baQr!(SR=5DvNAIk>uSNe#{3rI5^~)aHh>3&ryI7N0HZMbq7aOW{ag*9~1;<
za0!q?L}_0;iZP)JBVrwt_lF5y$l3zVI}R|2f?*vEfNVyg(3K4H1SJ>faOjy<{eD)a
zEu*PE@;2wS9pxcr9^~85^qjKUf**iIiSvzEakzc`PM~HnCbu{4?<<~y%$kNJI7@ru
zFY;*=rIU5TM6Y#_=R<5apcuEH(F0hY^3iZLbp-4!`?0(N1WUuBaLx6}T2^x4T=~mO
z(CjUpgK
zznSb(z;CHd4q7n@`tai{MXJf}x6#N7!HETf(|hi9P<
zC2!Lu+jm~jy%1{z9i3=0!gQQuG~Tq>wtOh!!o*|HIN5s^
z48v$tL-kc@xTfpezskKn-Ak)D$)9^KIk@B-S(0|br)XYdwV#VH*@l}swzo<2>AR!|
zdH$mG-#;?M)K+@!A!Sn^6ZltI!&~71^7q%Lz(xalLvO?%_fdZQ+$ADcZ^xBp?lZrkMOf&{mD^#e+Ku&to
z)0~d{SVrAW{9lW?glria?Bg9rZIh*_Nr_jbKE3vQ=UKi}&ku3r4g%?=oGfRH^H9kf=7TjOH5>sc}y
zyrDkP8Rsj6vU*L*rpWbS2h2NiODjpagM)m3{UGTwU}a_Xxi+3)U;Xf?t
z{}ML!#>y2^V4kE;yecdceC4;ISN>_;_2v4Pu>3{+7c}0=Oh!R?Zu`*Rfp`V9np9Lp
z1hnqw)-~c+^lZs>K(N<^Iq9HkC~2+2Yh
zoqp8E7ow@Frzmcme4zQCw)Xn5XUiJXl
z(wIp1Is;(_eKmK6C&K3xJ2z~GBujmuQJ-L!jhsHoKDjxGPEV)^4(U7B|5gnHM%jvo
zqu%v1Z~o(7eMb}lE}o#!VJG!Fr)16PguR~|@;U~N0)>a(5bVEE#!!4qEO
zf~1L|wCh0PHX(zJOaF1}t0?J5Q~oQGyqBcCBKMh&sXy^ZP>e{%f*JE43vIW@c{N%@
zn{&(+9Fmh`QZLxtT;K=L9^Wkhp4|suaFPc*^nZqC*;Mmjk_T9O05?VaE&>L^1;n|p
zH2`bPQ=gz+v9k|Ky=S_mNmM==R-6MdLVR&A@G+Pca>qZ&*ncVCc8uu}Fn4$4$u|Xd
znlr6)bZ~WbE#4196wl#NtgV31K)wj(>e0k)guO1CHxFr*HlcWW8;vF@=t1eI*I
z=FrB24kf=sO^-!;l6%^rghV*=I3VGY8KXw&FEm{D+su+#;P||Yjf20Rhix5pYmMul
z^Fh38bVuBeG@`f85AD<3!z0c3z
zi@~ptrPdN-)#pzEvCMCESh6dA^f|CGF>34`_h2qt-(Vt`I=#CzWJTnhoi;+kHY;m&
zVg;NKYdQM>Cdh8J+^ZEjtW4C5b(lyOlvT=~nOfbt=|B;@!(qyU+X#H9`Ny>k{F;ud
zc=D}p&-|5Mw|VziZzTk;4#bwBe-+$O3QsL~`9$`t;9Ho^$P#^7My=nq>0J3(`se$%
zN0{ki^tK2}TD3nVrTb(QerXi`OG(tH&QP8!@F*tIB1~N4ga_-%H%~-Yd};htQdWmozWjGJ)6=-;
zd+gOsHZ19CDZp^A10Da*{E
z02vR~E>^%*Fjuyv-^cN-xmT|0W#8zm5S)p1QM}(E;oEE@g)#BkU+k(dWT(n2(=!?p
zj27)9Eo1m7#vTaI2~uGvg>Mr{3GoxMMDvUEp~?Q^Z);uDT^B~`YBHl>i&(?c=dDU_
zaa~dQZ8=n84O9AK>afq*w>+p@O6nJp%#x1n4|6mmsH!!2yGh7z#Oc&P;XhcCz8oyJ*@_y$9Q
zxou33dKas$V2xWDLhFZ%w;CM8DY?7p4XhMVWY^D2D##W=4L<+JLhu`ljuQWibm5(Bng{7Vt|EEvLTtqWrt=oxAU5
z1N?SLc#-8wRIuFMpr!o&A>1gbdr1n+eerp|gTZ{jRU+%ch`)W>iz}at-D!fCJ#m)u
z%*q{=%L{))yel+Lk+j-wC{ON0eZ2G*Iu?zePZ-+ZIc5#Da6o(dy&ddgAq2t>Ba13l-J%r%@6B4_93sM0|X^Ldq;B%~P7As^2(
z7F}a;Uvj)JL=kgD;UsMQb4YQZvUBgYF@YLtt48HOL$G(i9Cc$*iVsSo0hIv<2V|>L
z5HE_o3xjBe6H?a?PEC(W8>#e1i*kHK@H0DW6-y`ijW@s5LW#qhs+63~d!VfWHxEZ*
z81v8J&b}-YoZs)K!bhVD?Q~4zV|oWX8pMG9AZkk@$?bz^^C=fnQSIbRj;AaV(dF2x
zoO3zLq^~RH#NPy$-3{nMbx$bZF7&{5kJg8A4JWf`$q*4mN3#?jIsVd8Qg^^RMJJ)W
ziS1I#0}rlNHrlEWHE6EnYlk6crTI(!lF}cBK3VX^)2+e34z5=DQ!zmrh^sk)MZ
zl!LCzN+_L{7jRx3
zxC~>BWi!>&n6EX^ye!3!x-^M1~Kw#=ki1&_7vlYl_cFM@WaFhuQbZu|2rp<0>6N
zbeV`$TifPQVHq#&0-?rR{km#87ldV;Qk}Ng7r9$Dd#(KbbMl6w*HgzoM~#!-
zm0d1vD6LtRMG`H)EIIrx64|1TW^NQzMbwoTUUx}@eXejGs1U^HiT?;bvP_3(aliK<
z0DxSwH{wRpYFo*~=u&@6E$?&sT_lGoolu7~$IHuKFF1B~NNm5%)h7|?jnUVVA3|$o
z9jQ4L(;0GN#-Sr&FlW$avB|bRzwQu{K&%v)&Lfj5HXwt6cKXYeB-IO#`
zeDZlrqSCQr6n4@&EXestQ=(I6&QHGknyGOXSJ7I~yc*0?sM(Q7|Q>x-&*Z4d#>4h%{X4;;%bI
zDREyA7I4ig-@9bgRg;TQAF^t3lNMiXj5dZ2=W3qYgrG6j7rnf^oTLtZpZ!(%P(z^F
zPAAOzIvJUjpOAAdbmB)w*pGmNIyu1y8lsJ0xNbS?4)d%FxIjwT=x!uYyTD?hOu$Ix
z!$7j$NUV6>z>OboWT~ZBqA~~@EQT0}OiEGTp6!k*hL}g^YU)crkuM|7Upts*d58EZmsMwKnjZ0uK_@}ys|W(QUM9H^;!m?{8O*w^;JEST09%1T@O_g_*XFLyqI5>kl(^^Tn
zU&%P0x2pIkQSr~an*}|cXHSE;p(It_akX6$71^r;EnCkX??;iMTGItL*5NkYbIc<&
ztA+L-SccBXT3f_Xb2;qTX_9QxRwoX3(12m~S?qnxFXdmClb9e>?0!ShWA=~3WSm~^
z&!B)rL4_=xYfLL;MoD@^2gm0!iq0`zeY$y^U||<6dYkkB11)MlPo)W*`Bd!A4uYp6Havz
zkw^4Wql#QK;@x8`18lxtu(KgVNcipq@WIEG=?!Oya>iLM2XSvYil1_L3FqF27Q9t{zAv!6l&Eh^xe08c3r0y~2Ir5U}ImNTe
z%B7^8ASYS=#$K+*-E8%0maW+Qj}FDy#PAoJc|`s1hu88nyL<@Q+1bF^vuA<4z*Sek
zhfLZ+2p3ZeGM7S_B9_U0!kEG8K(3#e=D9v}slR(c$O*aaHqn|0>^!Kl2lM6e)(IQ^
zWKAp`hMKCMD;;BB_32H
zkAaC*97s@h<5{%dcS3RRS@ujpMK9^#K&4Bc5)dCB4+ww?r*_xTN8r$0NRWOBDq8+y
zQ2_5
z{u5$A3CKRR@F>P#eXkTu1siWSKruByfRUsR9X-%Cm&5zdd^}sJ!nokeY@+F%#GP}a
z@MxRybdEptGTW_AK~9EP{I+k7Qz)qbFQCyP%(E@GXxZBz+02U5pxC&4z=>Y*^*Q)A
z7G<&&t>i-DevLNEGk^SQ
z7xSFQSfn1ul3;4=M;CBpvf9lJmF18z4Hi@~iu~EK#
zL$?l>oNdiPjD7gI`a(pK@?urwM3{l&pQad&Z*sQqzxMruTS9Rda9`Zn`xiAY!;WhI
z^_85nru&yZFAJ~DRZ^hyomY(^9Od>@;L5B|DSjrWlE3dME?jjx#*3&?nm!S{*eDIL
zsCaPSgg!O9l95IIXhun%xa>Xid6h{0Sks!2ce{}vNJ2CH0VCBZ7+?fvELEtuiK90>
z0J*Z^N44ZfTXyjGS^Exn11E2m?ZF7jTnA3s)o%L-^~UrS+}Xj_-wbYk$g-|1`6lyW
zjaJ-aldwkP$kKme%mGlM3mWm~*(LgV4Ga)Nd<4awo6&3jvlAe{2aw6ZD^r~-Tg4u{
zy63VbQ5h^y(0#w{QuZ6{T=2g-$C)NE!4ED;`>`Gvv#ef>8mv|JyZXs!3v#3un{<0q
zEE6>bMw?)cB_l7TttY6tn6?{r%X&>>$|{T=d_X5VRZ
z9XjcM@p7lT_bPqD|0-ydV2r-vchQe%EwcVbuR~OVx$J~
zB2q@W7ueO2z`@SExd4{B+cdoj>$XSoe#ngzc13PM9edFD>E$Y{BI*cls3oDOuvK-(
zcj+ez638OsPG+40(v-^6K)l!bj(1Fe&^z>mSHN9r4+ni%+_KB=7Pj$&e3w>Ls5qmY
z-2*Dw9dkQpJaqQKBTU|>I-YGWMka#uI6N4eE?B(|76aiCZ>gg%6`by(hzA_)G{1XR
zqmz`Alvy21QymXCP6|%pjZuSRmlBkAeeaphwk&?R;z*S|ekqrmABXJ#S8|@PFhI
zPAE3BUN<8_T>8uQVicXafjs+$@ZF`9zEXJrryB-EuFe0xqfygiix$J|kShZ)pOR4(
zCx+H!a;x|;N5WO0nEM?cX?<5Na4J`P>hl0lc#l|Oa^i{;HMksIwV?=zmSj)+(INJp
zES1^5axz@KPWSa@eFm($rP@W~_w?0`swM!gy?GGK{xf%hJiEH>z_VQkcnHmJ&p-qU
z9DRGNeL3+`Y7Rb~jssLy`@IBKo}@PJN@3x1l0`14W~B@}-R+kY}R?)2B7%
z)hdGrP=lMi_!5AS4j5Q2OaGIg>U{$69SZgxF<>;GA#x+XKH=5U+;8Un;>jH{sTjB!
zNG`@E6O-W*-+RE?j*xl5(-H|VhTe!kgCqUd^ad<)I(}3>TmJo?|y3D?9vrUmN2Ldt?|ZmnMU-Q%8T0StzrM&{iiM7ne|c
z;-RUNeH#V=D@n%c#+_qq0SlXYNW)T9*Y@DST1xOnO+AG*8Uf!u=Cl2-jqbmf9zkxWN4ROV)6Ij?HaM!NgtUCgOV1P6t=VnSO
zyi@o0{3I^ikeoxt`e2jwF1)SzDpxWtJ@~xW&A9k#c`e2N12N&j;+y627$yVG+Mo&~
z%P*Y!`HL9hHz?AzKUo+Q|0G|r#&3hkkh#1>Ex6rB_y0%WPi@jQ)@ecBJQtl&jwL1a
z$~iugJgiC^i-!1t35PU*h5;Tr;&$+a-zIN|wc2V9ReGDp@raM&YXnncrXPLa*BTUBRH0w{XlP`izt#UxcS
z@zO`Q{K$cj-elhQ$+2nugTw=y{GB0%$uDucH?bVjASjb_aO9=Z4^YdGEwht5yS>c9
z;~n@VH;YAWn0;XhE%34V1n+tl6_V+bRj`pHqdzMXS1gP58
z^IvypX?_X09cp%2zu8<+5v3q+_QJAa&a$H*Q?fC%=i02ppMZ|}-^a_*wJsmXhD-he
zG6CA{68P8!dSWq;yR$G(rbF@Z6fySi6zeB$-GkeLX*h2MdwNOP?eYlv@
zjDaa{YR~j#qQHsIKG`?R`H!J?1#nE{KXOYR&mImn%Hy=ZtO>Hw@qdqmfA(UhqT-?=
zV16)=0s-mtTzFt!wP$eehl|glKYM#8D@nY$c%~{KeA68Q7{eJ4VpphZqY+&0AfV!!
z|9&Lm!4K;HlA)zAbw|;MOF*Md0E7}OtUVj0fOkQQjR2UU!yJv)?>5Jom|ghPNH#M+|DuhPlW>3w#q5^SPOpiLz65i+i2syfG>b_7XO#gjkvnUQW%(x0-Z9`iRD%f!Y?9d0u
z*Q`kb;C#6j8!c4*)j(eai>$)DJ&C@9Y^;{PMUC2g=3!8Z`7CN`B>i{m9GoAozY}LI
zx%Xz3pTes|hdQe6Vk~Nq7&Lwc+hF`oC0~w+*YA`(9`eMQw)lrl-txXR=-F9(ukfD1
zXgKl7S1+~K5^*K+iIX}|3;n9_UN=~*xoxoGPljU1zimKfFP_L~XIsH;MGoumi|}4o
z-5=U_)#%)o@5CNq@qo$0hm!Zyrw`h#8Vc*}^7Up-`Cvy;BUow7EcdCXRtLPk8KO%>
z7iyKui|lOIDIg7`C{a?QjSTuh>}|56_r_FwonI0=y#C$k3^*jTI9N?B*H%5pVhWa7
zC^?1uZt^YCB5mTgq4dqejtslA&gz={UvuVSjlzB8x|ks@6TNc$KQnVGnugk0zv~aU
zaU&PU8VN~?J|^_oYoo%e!WnyEF@#8k^)fRjw|g29utqkNM06sdDi0>FykAp%c!b7|
zix07MUC{1NNo#+}d@gbGRiba{A!8B~zhCP3;H2sa@#8uPpUUGOa|^-YUoJ%Wnm#=8Q+Vi{&5$RDiu>?3#m
ztP8?Syz|-(><~KnzB)$h;ANRjOZ<#F%T@{Bcs^HXa-Z$ZXKOa?HWuZcQdc%+cX3GR
ze`tmO7drocNP#hi$)=P%x1ky>)a7B*Tx)hG;Y^BzAmhYl4h4lGuo;5~2`WvSLXkFG
z2il~;1_nUja?|p0D@#j`u9ppVzIF1pMZ84CZ~}39I(J@iv0|GS$PoCgMYpIU!Ag#(
zsOTmV5(N9|_==5_`*Vf@DH;SzP_9h9kTSH7k!~uD<~-mQRDVC%gjMB#hJ$I%^p_Hv
zilN&$C^8Os^KEXR65<2_wTxp|=)#PTp4q^ozpC2ne2yFj>=ZqlZWT}h+EEOg-^v8e16K^aatgqT>3Dm#kRa>ol}lOn3+QL9g_$yeI<~eAd)Tf0n<>5
zW6w&MpeD~wk)PSrjm=DaLIIMh^mDPmGNKL
zRM7pf{JFPk!w)oJJ6q)dey1sPHv^snpv
z>1)@leERgKmF#)`6g)nnH?FIZr1nw4LoEfbNHTrlc6QWmxN*(*?NW2uc`f^h?pRZ^t@=Imfr}}l
zuz6$jx&ROs7B+BN^{-G)hqJjyJA9?U{alORBso4;VGB%&f^zDcZUmV
z+qvA@baurSE{8>Fle}@_m~rv3))^IIHt*{KIwBGn;Z#3NcJU&cSA}t?YoZ_l;bDs=
zt=1;S?;LPf7}Sk(BwtFA&1-tK%J{>ZUzhp=`U?5${KY4#soYI?w?Id>K!$ttLlOS|
zua`=_U6mito%*aeccKm?e7gx?7JXY*;15zw4Z6F_ki~DDWzcQ+x%h<=<4^#(nBE_B
zZKm{(4Uw4O)yRrPm4CQOXUy@I!zQCV$^~Kd%(Kd(>k$+yfg2$sRV-2iPtPCH7pC0f%|72>H|P*|;*8b9
zAXDWO+d0cc>10O(SO9B&=JLMiW4eo<*7r;VZ-{RU*zR1RJbDerJKF1f4r&Cwx6JFF
z>+7DpTgw_1PSc&AfqtJp7eJl4HVWKy|FT8u7c1IWla~MjD}M7=(MK;sIX$AGA_^%3
z4YvM&Vt{^-pFs31T3qj+q$q
zB8yeLStua%;Lpl|+mwhg$7LoN?@2XhFl_6U0WoQeiJ@O#W`bc9cajeQQvy4^y77Ex
zOG_&L!R4H4{95u1&|8Kpv#fB
zDDDX9$+j|o6LX$pn5Q7A`IzSp)B
zL0G@IEmvY_Yx2h6=9jvd-*F1|)6*M%w}QO|S&R`f*tuP14+^GXld%WVN<6(9sPnK
zuvBH{v?Qttr!&UnIf<7OtUzfKvN|`=*DZ@<ot~+=sgeqzG6iM)mnh0B
z0>$V*s!UJmG_y5QdagM0gt~-FcWncG7zv5YJ
zdeI$P#Y%MHE{UR}<1#x)Tmr?vS}O8^YyaejpdAOWvj
zDr=_V$lltr$nv{s>1J&ae>=qNpd!>x=b;)6h3MjdHG@jB!3n1#kn#TI+dVgF|1s{|^J9)$5^e!rF=>
z%|1pKmcgwv&7HHYLr;fWUaU}QQoYVk8$`QZ&RW}Bif8qsu($Kt&AG8dTqaR8z`n>i
zh*=+$nHu#qPERRQSKb&^liG-MrqHWai>T2JuYe(^+sx%-KQe@146A&c>1^u=f>qmCKExSL_A^B++6;FhX8hI0n~VErgRMn*q0
zh22)(mkq&+Ms3+4^s{ZdxQCPw7TK@sDS~^b^SrU4C+XKVLZDiPeOg5NogI-T(rBn84uabi@jAFw9px=PW3`7f~6D)UMseni=n!>;cK(ag6M$!8YMym6U|HyWw(OlaKUN>G;q%Stz(vKG7kkS^zY7z8
z4b{IoT}2yD(4vjA{(?!ZrZ`}?=<5R-DxPEdR@P1KKQ-LCYDK^Ble1{_dg0x93aLA1
zdUCfe;?NwRqkT;Zlbu~YPznkuG!wA7fUjg(DvNht_Xfgy7A)i$hpxLgH@8Im`uP*H
zNiX8LUxm^j_D}ajYhfQzxlwfAgiO1-J?yT{Q6~wnBsdoQz@yly=Z$v_oRdemHC_Qx
z@lyd{bs&8Ao9(@M8l~McQP*U#eZt)o_s+b@&nDG+;j;-A9m=kti@O{_JWwb3f{ln8
z6{hijLCb)o?p8|w+k=04J3GfnUqFm(Y1!us*50vn5WaTE;(|GhfL}}EJ!Eoa->HDA
zskb@y;_ue{vkviNkt`+yGC9yEDgaWYLLYKdmjlSX#J)?T@>BGJMt=?9f_8X+<4iUk
zFRB$F2}%7f`eje?qx=$(37DpVI1|(Y7-9F)mRXVz=lN{*$6fxrOt>uG>cvhg=n++|
z=Fns!-qFyN?avNk*)9Ji6e)x)E@h&6U#0OY$<1A^|pD$0g#!YoH~ZAB_oMT6qn4*YnF=ay}6=Dnb(YLGA|j|j7!5RGn?zm
zx@6ppdoQkg@9$0Z`FwuA-}mwThwFNu^*ZPEe2w#by(4tAR8Jk}I1U1VPC?aF^gtl`
zU=WCI`q&ZR8>#-uIS@!o7^&59i^bZ*|K5@G|
z=2Ub!BdMe_=JE9W(=#ruCG-}aFLck$eAl>pmg_=WiNkTfh~Q%^xv}Dvxup*oA3N5p
zK=Bw)nop0IXYUWsVl79E!B8d%l31*O8#MW)-?KA}_svjIRSkcgzzWPH>_JY-+vWPj
zLm*J$t)fSwN0#e|+VX)2dP=2Kw_y+=*83Ir#p($(t@p=Yq}l=s=*s(YscNHxLjg
zxZjf{Wp0KU1hVolXAr302HKxDKldCeN(GwS8v!1p0MCmzfT1+t3&~=w{UG57LeFdYL3)VGM-|J8Cur<`MFN9L`^NM(cy-J)y+o@=Q!j
z8%scQ)RnvR$3UR0{$!A0d%j)lcat2On4~>+A)`CC&@BF!L|ewoz~l<|y9`zX8C;LB
z%tis)fmR33&DN&q_aK6L7>x&yg+aI-x#e>giWJgt7}V3KOY?Z*U!*>dRvsU
zbPe9t25RN(Tn@e(r}p8&!D@i9vWhi9NHoNrBKNDjE|g+LereeHZ+^vH41IPq;iV7>
zfn4c3$56q5l)=Cckd738@E}$91h9BD(0(Vnu^_&wAU--3sofPD$Tg_nOoZSgO^D;T
zBy@mL@EtF-VKL{;XvE(&6sof++R;X}&j-RxP5JZV6|fug-}s?2*nleV5;J@|6ue}F
z-es3#{@c496m(6h|JGKYvT|>x->;(A_vwoct4O6m;LUl9wFi%HB^ASJJz6wHnsa&I30a=wtZ(
z^_*n_*c?tFAi`>-Gvxhiz)PP_h$VozT)(hnqb)s$3=nyD-<*XaMC+zQBJrD})-h0o
z0&S;P1HlB@bAb*3^ZwlDVLx0Nm!Kt%Tv&g8l)K|5X2{4g-L=Var)G-0T5H)x@!aSX
z)IiiYVT7Q&t;|8EVP*VF?XNUpHDH#3vM9J}Gs#_q{BWZ3P=4{XIIN=zPrRm+y<*kNzubr9U
zi5~N!5B{@FarZ&0ehjA}I}Z}0jtyY3y#=gN-(Ym6d^QW+Y3KZ$q?{He-Fp)yt3B64
znH8=;cgIJX1)#7xm+U-`4crLBJ%0aTp6rh*19m&YZ#Qv}^)GBEV9sdie!Gik%Bpkw
zc#JEX^55f_ClSq%a~w()Y9cZh@&}bz+>K=5D?i(ZkkFXiTgusGuV(0Bm9%HKm+~Td
zmvB|jkCR8q69I-lY&K0LY^6eVrGk?jc9$I|=Bq|T=pyBkHfoy*B2X~=pPb4V#ErTw
zE2N%-Unx>^>{d&qN{9AL&;i5nbcp3($|nVlZQ)MSMlYnq(E0I=Ro8DxBH2r&mJ0Tc
z%e5Re^Amn!ex}QocfmRJ+sLfkFBqPGr!$FFQDYHM_JjdW-Wu-Xqn{@PiBE$Hmx;DKd
znlAUP4wb)Kqe5SpV|w%r!|6g}>6x#-xOBm*nAG8W
z3m*&{gAm@G{K;ys%j!4Dhx9Wdvaz&G{D6koDP(Qb)CVvrBxb)0cBoqIFO)FNi8s1!}()+&l
zOOG>!6E)>M>8jdBs8Q9RcLXQ=(2|P^W%|?el{CP*sG7X)<>26$WPggVzyDQ@y1TnO
zdf`7HG!%K*;j(EgNMav^|6*VxV^$R7eb&NA6uc&rHG)CzFzDaLq%!oeirCR5N?qdL
z0c^`3d@jCDcLo)lLj(-S-~A&1kBM?Ff>D|n1KWL5A{UA!LQX?97?UCD(Chs
z&L1ok5eyzaUV}zsk&1zVns}=TtqCUpBjZ4T5l}?E19dI)xU|0>G-kE!%tWUH|M@fW
zJ!SeUVvP6m^$^8q&krbK{=$Fez(+S2JKQQSFOL|2TII)ZIV@{J{xgfyj8*|nO-+cr
zhK7c|SvA3sq-~Sc9C?Wvv428Pp+BF=Zx=KpJsDwD_9bS2s(;o7*qcr^C3L2|20i6*
z58}?(mGD09+}-ud>;I958c6C9(w+Uf0MOeXMQb_C3w*2t?c2Y7E0_gCTB4N@TZ^;W
z>WGX$>S5?WzU5v(>5iP796yvK|2qr3G&EwUhDc3I1w%6%7a#Z{V?^*t6Zix$(tLzv
zmpmy;)<9@k{?0C)*j&C#QbSmnuKO+G-I+$U5nFLP1?mVG3|74BBfRrN4>~}3!}EPP
zP+@avlOOt{0N=HKu+s;CR;VO{C2Bwi+sL445E!C?XD
zEfF=h_u3QrS%k{GqLLB~#Mames_Yht6Ta&X9dL%%8Y8w^(Q=(>G$yE?yE|ZjzFBTN
zwC#w*-6EC77!cNPQtNV+V|1w-EAEIqTI1rBaVOSRjvE{dNM(N~vlcF1$BRRkzRb@X
zS;m7gTR+mQyJ_v=NiQ>Ko4%RfmnpQpQ8q;0p->Kq5=te<^%7ls=gc#y_&V!o1ehXP
zD7(d0;K}8zj_BFlUb=vT%(&EIV&vB!YjVYyQO{fyn}ARtk;M
zLXD$KXtPHV15)<>J>$vMYo7&7`C+t3_hNQMW%SLOb4koP!B5OW0HpiQN9h;0tfCHy%kr!yxj%O1auqpFvzrfHpQ?{{S=vRnZ0$`CVWFrL!Z~w4pp>A8f4JP%3U@6xd0GV
z`8$5d2z9Y7vQwwtcxG{gH!^-*7e;x!N!+(q(|%4ZG*2!m@1C
zhle}YB`o{=MT>a`m!CH@`AvFo++GpGY|(W}BjjJE^26x&9Uoz>gaL76^{T4Su;W}6dUZ^zh<%6%SaIAlJ
zma;=CIS?|tPr{-tY=6uAotRw4T=Th%>582shW0@FHoIMVdU?Oszw=huRYcT;AQPPQ_a
zEYQ|#INQTo;lcV=mabZ*m(+c^LAmo?Qw+bWnB%V#A~cz6?Pn4}@9ptNLgsEa}!3W{P0SI{N9{NJaZtm44?I+s>T(XQbvPm21D_lsCbEIDN
z?UosjIl3Ptv|Nc_TwGK$`1IO%hM*1S;Pj&;3p$<{+Sp;VYQFruuqEh_dz9_ZnBa$<
z^HS|wUy7R=x8qzOO4xU}DcqN(Lw8X*#AHq>z77aA`)b0L`3JanVWL~v&IwG=e*zSb
zK~NB7-DTi0Yg367hvko#%~^jY|1x@CivSj
zVmG2XjVUyfWoxul1tdpp@EC6--O0b5W%Z|nV94~S`VuarrRr1RrgDYxl!~)G_cnf$
zyGzan!NaJpc!Owg!%f@>eX56Jo;`-FqC$JFEA{H1eeYXT15sd&%)Kk8?(LmFd9U=C
zHX0=s=lLb(4%@8~r7hK&y1A^fkBHLY>(U!)&xqQ~DFedocH2Q!df6;WUe#Vfa{kNa
zC5NkFYH3(In#Fj6FTqI2|I53Lb`b{o7%
z57JS$ANEv3jE@VbBbZX<1j~1vn^%(&dAJO?E<(~Vxd@RL#VHeE*aA8*JBbwxYcr~_
zgd+4U&LwO2?;!DFbSpD{4e^E3;uy(!o6w$i=khya_;Zbrxn4Z{_h!qgO>pF?LM)C(zqcih+X~m)p9DDmK-vl-aM_`HTY}9jR70p1C*XuHLQIG&vhMw4X5+b!&BPttVyA
zMAO75nM_Vb{~qhZ!hJq#gQ4BsYqap$7`a=&GY5|5zE8bCf@ye5ji8
zsC_+#S>)Q4>+Jh52Y9Z9A*yp%*g2IRkB|1~!2P8+!iIWGW^67yCtTZ!mw8_qj
zLlk|b!a(8!Jv-YME~r|4fL$wT^^+>>ugv?-da2}DRjRl@rM$Q4bgHFAcQ#4@1ukg{
z)U$1vjh0nMY^}KW+T8Cb^%4
z%r(z$VWXM*SabpW;{x|IVgaRe?P>q<^T15`=xYH
zN|QCeC%TBHprWddD&8e`YC_^AC7)GrD3n~Llc%Do2tG#|V-EYn3fer_@k
zf`{lX+ek&vPbrx4Ok@j1QCE0kZJ
zEwFjlgSe(~%#q=Zd(U1WxTwDVYPEvE+OgALos7x|Rl|ir%UCzZOp)GQuB~K2XYlE?
zGT1JS?eB>=22%7jzVq0633knb(|QB}_OHHf%YK7yQf^UD>@}*3GGy+pp-xJ+p2)ZJ*|GI&jXj)FPsz
zqt7(b0g24jTC}O~RSd_r`?3n}g%b^J5&G;SiPIGROTC&>u+eQP03q7mj?)}N@
zz1shORx#(*ulJ;V9ltIEsX(8YV=zqHYUb&Gdi%#Z0#b6JI|lp|-8Yy26P|(21q0ts
z(*t`5GTV1f{%?;JdJu+cJ^}d!M1mR^3oi3CklIh}6_np<y^{b6x+H4!W&4M3IGl&?)RkT)-U=G;U`0v8n&s3Ihven0;wQkRlp
zeKoWf2jrxK``gO=g&C_YRl*wuV)s=7i1dYQAn@VwkRP+5C0lzBR+WnbO3r;_GabQv(1HW
zL>E>l>nz0SpGI;O{qIV3j95?X*%`{6dOKb2{%U!4i4%kvE2C+^PRHNT{(`*&wtr;qEOnN
zD!e22%-frJhmgym{3FoaBnJF!oij9hr;w2$*k|QdSC9$2R4}kI9sVH@6H|j6)pfQF
znnQzRC4FyrXu7zgjH^>|-rfFv3}RnXXV}_4HNfr{`1SBCkP|-rIZ4D644tTPJV7WC
zK2VlIRy)wqBa`Xyz(9HbbHL8Vo~WL_)m2t_3lqE_-S!M32s%LGUQPn1d$yeS;`>kHg+V8qd)0-}itnc#jrw%t
zJ1N><(~Uww4K_0T#;iTIb+CeexB9OK47!Z(M;*$>xqGRrOzJj_V5>?rc9TloP$6&&
z+yW#NSlfSqxA11t^xJy5C(YjX54TRg>+IB_w`H8UK&}*{1I{h5C;OOcU`&JC*Vg0Y
zq#E0cW`BjSDBUjl&X}FaOaI@FbiQuU3b>H)D(sCT7zR6>YbWpZ`-d4Hg-}8N+mjCs
zuCzSG^%WlRT`G9P^ia7M+{c3p7~>y8Fl4FYX=0m-ymGsA2}U>S9upL8eG7i@{J)-9
zruD)|0wIskg+U++hvG9{Yt2jhdiHO=0W|o=E?qUROR}L1b}kFjBDHn&*x%F{a0&_^
zk!kS~Bh;&tH%DoWK)wZdqJ5G1f5dYB`Wx)x2rYA|n_vgZMKl*a?z1~aTO=IaX4yT&
z@HagNj2G73R;C0USl}iF+;`(0q*;m<=`XzMV{Osz;(tRbUyKKf+|GWR&h+20PsYng
zGasSs)E{#uMeLvk2xL0R#>kQrtanKP_LdR}H*2Ka#g6~?dD|3L#T$+ee6t=EWV)@V
z*uW6~pNX1lJ6+wt@PE;t;dG0ONxkg+&>H^+$NJ?&>z$g9M$-9rB4%43jFJBPlwy6;
z+7Rd-e{h`MTYK8Tw<`ah=e%_HD}MaX4n^r)n|9qn(Hlm$^jT)mfXM6*!U3ao;i5D{
zv#*F1^R(3MRXnGpeoFkGIf%O~BWu+WNn)1Vew0{crZ}*O@dw`JJ&k11M6ig($D6}@
z^BLTQV9t>1zO}^q7iFl{yWX>Dlk$&>+#1aHlY{>T>UuZXL|$t|%_H*9+59vn6B
zTej5rb>zW1j3o3F55HDjN-TLH7;2tcNOr`7WJ%yj|K8q%WOe`tDU7JaDLe2wWp#)4Tja1KYrL`IJsHJf{x%xciI8t_x)b<;o$MU
z;bDP4egVS=wGj({BzWojjovvex+?<{^5(7RBVg!_i8L@2IlByoUPagShHa~DGg@_}
zDY(<$tU?+gwjj>+Uw*GPCr#hxrT-`RTzR^W3#s;Cs9?GWA}^aly_!Q5D?ZX|QM=S7
zFRv3}fxXy&+*lSy5h9PGb-mWx&Rf*KWKsI7cYU_nAm@+k_n-sK