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 <noreply@anthropic.com>
@@ -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**.
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-vacuum.png" width="32%" alt="Vacuum card: docked and charging, start and dock, the cleaning mode vacuum, vac and mop or mop, suction and water">
|
||||
<img src="docs/images/guition-history.png" width="32%" alt="Sensor history of the last 24 hours">
|
||||
<img src="docs/images/guition-history.png" width="32%" alt="History card for a temperature: the value now, the highest and lowest moment with their times, a line over 24 hours with an axis in degrees and clock times, and keys for 1 hour, 24 hours and 1 week">
|
||||
<img src="docs/images/guition-page-3.png" width="32%" alt="Curtains with open, stop and close, a kitchen timer counting down, a scene and the sun path">
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -128,17 +133,27 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
|
||||
<img src="docs/images/guition-curtains.png" width="32%" alt="Cover card for curtains: the position slider and the sideways open, stop and close keys">
|
||||
<img src="docs/images/guition-garage.png" width="32%" alt="Cover card for a garage door that only opens and closes: its icon and the open, stop and close keys, close disabled while it is closed">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-history-touch.png" width="32%" alt="A finger on the history graph: the top of the card shows the average of that hour and its time, the graph stays as it is">
|
||||
<img src="docs/images/guition-history-door.png" width="32%" alt="History card for a door: closed now, opened 6 times for 22 minutes in all, a timeline over 24 hours and the time in each state">
|
||||
<img src="docs/images/guition-history-person.png" width="32%" alt="History card for a person over a week: home, away and the zones they were in, with the time in each">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-vacuum.png" width="32%" alt="The vacuum card on the CYD: state, battery and charging, clean and dock, the cleaning mode, suction and water">
|
||||
<img src="docs/images/cyd-climate.png" width="32%" alt="The climate card on the CYD: the target temperature with big minus and plus keys and one row of mode keys">
|
||||
<img src="docs/images/cyd-blind.png" width="32%" alt="The cover card on the CYD: the position and tilt sliders with their values beside them, the battery, and open, stop and close">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-history.png" width="32%" alt="The history card on the CYD: power over 24 hours with its highest and lowest moment, an axis in watts and clock times">
|
||||
<img src="docs/images/cyd-history-touch.png" width="32%" alt="A finger on the CYD's history graph: the average of that hour and its time at the top">
|
||||
<img src="docs/images/cyd-history-door.png" width="32%" alt="The door's history on the CYD: a timeline over 24 hours with the time open and closed">
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<int>(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<unsigned>(seconds));
|
||||
else if (seconds < 3600) snprintf(b, sizeof(b), "%u min", static_cast<unsigned>(seconds / 60));
|
||||
else if (seconds < 86400) {
|
||||
unsigned minutes = seconds / 60 % 60;
|
||||
if (minutes) snprintf(b, sizeof(b), "%u h %u min", static_cast<unsigned>(seconds / 3600), minutes);
|
||||
else snprintf(b, sizeof(b), "%u h", static_cast<unsigned>(seconds / 3600));
|
||||
} else {
|
||||
unsigned hours = seconds / 3600 % 24;
|
||||
if (hours) snprintf(b, sizeof(b), "%u d %u h", static_cast<unsigned>(seconds / 86400), hours);
|
||||
else snprintf(b, sizeof(b), "%u d", static_cast<unsigned>(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<int>(whole.size()) - 3; i > 0; i -= 3) whole.insert(static_cast<size_t>(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<float>(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<int>(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<Run> &runs, uint16_t slots, float fraction) {
|
||||
if (runs.empty() || !slots) return -1;
|
||||
int slot = std::clamp(static_cast<int>(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<int>(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<Run> &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<int>(i) != keep && distance <= snap && (nearest < 0 || distance < std::fabs(xs[nearest] - x))) nearest = static_cast<int>(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<int>(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<typename Point>
|
||||
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<Value>(std::lround(x));
|
||||
out[at].y = static_cast<Value>(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<float>(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
|
||||
@@ -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<std::pair<float, std::string>> ticks;
|
||||
std::vector<uint32_t> times;
|
||||
uint16_t slots = 0;
|
||||
std::vector<history_view::Run> runs;
|
||||
std::vector<HistoryState> states;
|
||||
// Home Assistant's words for the states (raw state, words), so the heading follows a state that changes.
|
||||
std::vector<std::pair<std::string, std::string>> 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<void()> 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<JsonArray>()) for (JsonVariant moment : root["xt"].as<JsonArray>()) {
|
||||
if (next.times.size() == 8) break;
|
||||
if (moment.is<unsigned>()) next.times.push_back(moment.as<uint32_t>());
|
||||
}
|
||||
if (next.line) {
|
||||
unsigned i = 0;
|
||||
if (root["values"].is<JsonArray>()) for (JsonVariant value : root["values"].as<JsonArray>()) {
|
||||
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<JsonArray>()) for (JsonVariant tick : root["yt"].as<JsonArray>()) {
|
||||
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<uint16_t>(std::clamp(root["slots"] | 96u, 1u, 96u));
|
||||
if (root["states"].is<JsonArray>()) for (JsonVariant state : root["states"].as<JsonArray>()) {
|
||||
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<JsonArray>()) for (JsonVariant item : root["seg"].as<JsonArray>()) {
|
||||
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<int>(next.states.size()) || state < -1) return false;
|
||||
history_view::Run run;
|
||||
run.slot = static_cast<uint16_t>(slot);
|
||||
run.state = static_cast<int8_t>(state);
|
||||
run.begin = item[2] | 0u;
|
||||
run.end = std::max<uint32_t>(run.begin, item[3] | 0u);
|
||||
run.seconds = item[4] | 0u;
|
||||
next.runs.push_back(run);
|
||||
}
|
||||
if (root["words"].is<JsonArray>()) for (JsonVariant pair : root["words"].as<JsonArray>()) {
|
||||
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<int>(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<unsigned>(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,"history range")&&history_hours!=hours[cmd-160]){
|
||||
history_hours=hours[cmd-160];
|
||||
lv_async_call([](void *){if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!fresh()||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<unsigned>(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<uint32_t>(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<c.count;++i){
|
||||
const auto &a=c.points[i],&b=c.points[i+1];
|
||||
dsc.p[0]={ox+a.x,oy+a.y};dsc.p[1]={ox+b.x,oy+b.y};dsc.p[2]={ox+b.x,base};lv_draw_triangle(layer,&dsc);
|
||||
dsc.p[1]=dsc.p[2];dsc.p[2]={ox+a.x,base};lv_draw_triangle(layer,&dsc);
|
||||
}
|
||||
}
|
||||
// The timeline: one rectangle per run, drawn in the bar's own draw event instead of an object each.
|
||||
inline void history_bar(lv_event_t *e){
|
||||
const auto &h=history;if(h.line||!h.slots)return;
|
||||
auto *obj=lv_event_get_current_target_obj(e);auto *layer=lv_event_get_layer(e);
|
||||
lv_area_t area;lv_obj_get_coords(obj,&area);const int w=lv_area_get_width(&area);
|
||||
lv_draw_rect_dsc_t dsc;lv_draw_rect_dsc_init(&dsc);dsc.bg_opa=LV_OPA_COVER;
|
||||
for(size_t i=0;i<h.runs.size();++i){
|
||||
const int state=h.runs[i].state;
|
||||
const int x1=area.x1+w*h.runs[i].slot/h.slots,x2=area.x1+w*history_view::run_end(h.runs,h.slots,i)/h.slots-1;
|
||||
dsc.bg_color=lv_color_hex(state<0||state>=static_cast<int>(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<uint32_t>(span*i/history_view::PARTS),finish=h.start+static_cast<uint32_t>(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<int>(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<int>(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)&&(current<c.bottom||current>c.top)){
|
||||
const float margin=(std::max(c.top,current)-std::min(c.bottom,current))*0.06f;
|
||||
if(current<c.bottom)c.bottom=current-margin;else c.top=current+margin;
|
||||
}
|
||||
const int label_h=lv_font_get_line_height(small),edge=large?14:8;
|
||||
int label_w=0;for(const auto &tick:h.ticks)label_w=std::max(label_w,text_width(tick.second,small));
|
||||
c.x=card_x+edge+label_w+(label_w?(large?10:5):0);c.w=card_x+card_w-(large?18:10)-c.x;
|
||||
c.y=card_y+(large?22:10);c.h=card_y+card_h-(label_h+(large?16:8))-c.y;
|
||||
if(c.w<40||c.h<24)return;
|
||||
for(const auto &tick:h.ticks){
|
||||
const int ty=static_cast<int>(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<history_view::PARTS;++i){
|
||||
if(!h.has[i])continue;
|
||||
const float y=history_y(h.values[i])-c.y;
|
||||
if(i==0){xs[n]=0;ys[n++]=y;}
|
||||
xs[n]=history_view::part_middle(i)*(c.w-1);ys[n++]=y;
|
||||
}
|
||||
if(n&&std::isfinite(current)){xs[n]=float(c.w-1);ys[n++]=history_y(current)-c.y;}
|
||||
// A moment takes the place of its part's point, so the curve stays smooth; its ring goes where the point is.
|
||||
float high_x=history_x(c.high_at),low_x=history_x(c.low_at);
|
||||
if(n){
|
||||
const float snap=(c.w-1)/float(2*history_view::PARTS);
|
||||
int kept=-1;
|
||||
if(std::isfinite(c.high)&&(kept=history_view::through(xs,ys,n,history_view::PARTS+4,high_x,history_y(c.high)-c.y,snap))>=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<int>(std::lround(at_x));
|
||||
const int my=static_cast<int>(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(ly<card_y+4)ly=my+ring/2+2;
|
||||
if(ly+lh>c.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<int>(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<int>((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<size_t>(h.states.size(),static_cast<size_t>(rows)*2);
|
||||
for(size_t i=0;i<shown;++i){
|
||||
const int lx=c.x+static_cast<int>(i%2)*col_w,ly=top+static_cast<int>(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<int>(std::min<size_t>(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<c.low){c.low=current;c.low_at=h.end;}
|
||||
}
|
||||
if(std::isfinite(c.high))c.first_text="High "+history_view::number(c.high,h.decimals,h.unit)+" \u00B7 "+history_clock(c.high_at,h.hours==168);
|
||||
if(std::isfinite(c.low))c.second_text="Low "+history_view::number(c.low,h.decimals,h.unit)+" \u00B7 "+history_clock(c.low_at,h.hours==168);
|
||||
}else{
|
||||
c.value_text=history_words(t);
|
||||
if(c.ready&&h.active>=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_style_selector_t>(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<history_view::PARTS;++i)if(h.has[i])widest=std::max(widest,text_width(history_view::number(h.values[i],h.decimals,h.unit),big));}
|
||||
else if(c.ready){for(const auto &s:h.states)widest=std::max(widest,text_width(s.label,big));}
|
||||
const int value_x=pad+(large?8:4),value_w=std::min(widest+6,(width-2*pad)*11/20);
|
||||
c.value=detail_text(detail_root,c.value_text,value_x,row,value_w,big,LV_TEXT_ALIGN_LEFT,0x1B1B1B);
|
||||
const int words_x=value_x+value_w+(large?12:6),words_w=std::max(20,right-words_x),words_y=row+(row_h-2*text_h)/2;
|
||||
c.first=detail_text(detail_root,c.first_text,words_x,words_y,words_w,text,LV_TEXT_ALIGN_RIGHT,0x5F6368);
|
||||
c.second=detail_text(detail_root,c.second_text,words_x,words_y+text_h,words_w,text,LV_TEXT_ALIGN_RIGHT,0x5F6368);
|
||||
int top=row+std::max(row_h,2*text_h)+(large?10:4);
|
||||
if(d=="number"||d=="input_number"){
|
||||
const int slider_h=large?24:14;
|
||||
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+(large?14:10),top+(large?10:5));
|
||||
lv_obj_set_size(slider,width-2*pad-(large?28:20),slider_h);lv_slider_set_range(slider,0,1000);
|
||||
lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
|
||||
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)detail_index);
|
||||
top+=slider_h+(large?22:12);
|
||||
}
|
||||
const int range_h=large?48:28,bottom=height-(large?16:6),range_y=bottom-range_h,card_h=range_y-(large?10:5)-top;
|
||||
detail_card(pad,top,width-2*pad,card_h);
|
||||
const bool empty=c.ready&&(line?std::none_of(h.has,h.has+history_view::PARTS,[](bool v){return v;}):h.states.empty());
|
||||
if(!c.ready||empty){
|
||||
c.status=detail_text(detail_root,!c.ready?"Loading history...":"No history in this period",pad,top+(card_h-text_h)/2,width-2*pad,text,LV_TEXT_ALIGN_CENTER,0x6B6B6B);
|
||||
}else if(line){
|
||||
render_history_line(large,pad,top,width-2*pad,card_h,small,current);
|
||||
}else{
|
||||
render_history_timeline(large,pad,top,width-2*pad,card_h,small);
|
||||
}
|
||||
history_ranges(pad,range_y,width-2*pad,range_h);
|
||||
}
|
||||
inline void show_detail(unsigned index){
|
||||
if(index>=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;i<t.history.size() && i<24;++i){if(!std::isfinite(t.history[i]))continue;int h=maximum>minimum?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;i<options.size();++i)detail_button(options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
|
||||
}else if(d=="number"||d=="input_number"||d=="media_player"){
|
||||
if(d=="media_player"){
|
||||
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
|
||||
int w=(width-pad*2-2*gap)/3;
|
||||
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
|
||||
}
|
||||
detail_label(detail_root,d=="media_player"?"Volume":"Value",pad,top,width-2*pad);
|
||||
}else if(d=="media_player"){
|
||||
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
|
||||
int w=(width-pad*2-2*gap)/3;
|
||||
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
|
||||
detail_label(detail_root,"Volume",pad,top,width-2*pad);
|
||||
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,lv_color_hex(0x111111),LV_PART_KNOB);
|
||||
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
|
||||
}else if(d=="weather"){
|
||||
@@ -1118,6 +1604,10 @@ inline void show_detail(unsigned index){
|
||||
}
|
||||
|
||||
namespace runtime_tiles {
|
||||
inline void history_received(){
|
||||
if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN)&&detail_index<model.count&&
|
||||
model.tiles[detail_index].entity==history.entity&&history_hours==history.hours)refresh_detail(detail_index);
|
||||
}
|
||||
inline void refresh_detail(unsigned index){
|
||||
if(!detail_root || lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) || detail_index!=index)return;
|
||||
auto *input=lv_indev_get_next(nullptr);if(input && lv_indev_get_state(input)==LV_INDEV_STATE_PRESSED)return;
|
||||
@@ -2478,6 +2968,14 @@ inline bool was_fresh=false;
|
||||
inline void tick() {
|
||||
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
|
||||
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
|
||||
// The history the card waits for: drawn once it is here (a finger on the screen holds that back), asked for
|
||||
// again every 30 s, and after 8 s without it (an app from before 0.2.59, Home Assistant away) the card says so.
|
||||
if(history_chart.status&&!history_chart.ready){
|
||||
const uint32_t now=esphome::millis();
|
||||
if(history_fits(t))refresh_detail(detail_index);
|
||||
else if(now-history_asked_at>30000)history_request(t.entity,history_hours);
|
||||
else if(now-history_asked_at>8000)label(history_chart.status,"No history available");
|
||||
}
|
||||
for(unsigned i=0;i<detail_action_count;++i){if(waiting||!fresh()||!t.available())lv_obj_add_state(detail_actions[i],LV_STATE_DISABLED);else lv_obj_remove_state(detail_actions[i],LV_STATE_DISABLED);}
|
||||
std::string status=waiting?(t.confirmed?"Confirmed by Home Assistant":"Command sent..."):t.domain()=="cover"?cover_status_line(t):detail_state(t);
|
||||
if(detail_status)label(detail_status,status+(!waiting && !t.unit.empty()?" "+t.unit:""));
|
||||
|
||||
@@ -143,8 +143,10 @@ Click **Save & send to screen** to send your changes.
|
||||
- Climate, vacuum, and cover: tap to open the control card.
|
||||
- Long press a fan: speed, if the device supports percentages.
|
||||
- Scene/script: tap to run; button/input_button: tap to press.
|
||||
- Sensor: tap for the history card; set 1, 6, or 24 hours on the tile.
|
||||
- Binary sensor: view status. Select/input_select: open the picker menu.
|
||||
- Sensor, number, binary sensor, and person: tap for the history card, for 1 hour,
|
||||
24 hours, or 1 week. Long press a switch for its history. A sensor's graph on the tile
|
||||
shows 1, 6, or 24 hours.
|
||||
- Select/input_select: open the picker menu.
|
||||
|
||||
From firmware 0.2.7, twenty tiles fit across up to four pages. Older
|
||||
firmware keeps the limit of ten until you update. Under **General settings
|
||||
|
||||
@@ -176,6 +176,43 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
|
||||
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
|
||||
profiles must come from the set. No changed preferences or keys.
|
||||
|
||||
### Compatibility 0.2.59 / firmware 0.2.51
|
||||
|
||||
App, both board profiles and `components/smart_display`. Storage, tile protocol, preferences and keys are
|
||||
unchanged. One new event from the screen and one new message to it; each side ignores what it does not know.
|
||||
|
||||
- The history card replaces the sensor card (24 bars from the tile's own graph samples) and the switch card (a large
|
||||
toggle) for `sensor`, `binary_sensor`, `switch`, `input_boolean`, `person`, `number` and `input_number`
|
||||
(`history_card` in runtime_tiles.h; `render_history_detail`). Opening it, or choosing a range, fires the event
|
||||
`esphome.screen_history` with `inbox`, `entity` and `hours` (1, 24 or 168) through `send_homeassistant_action` with
|
||||
`is_event`, like `esphome.screen_setting`: no permission to call actions is needed. The card asks again after 30 s
|
||||
without an answer and says "No history available" after 8 s, so it stays usable against an app from before 0.2.59.
|
||||
- The app answers with one `{"v":1,"op":"history"}` message through the screen's `screen_message` action
|
||||
(`Manager.card_history_loop` → `answer_history`, only for an entity on that screen's layout and a screen that
|
||||
takes whole messages). Numbers (`kind: "line"`): `values` (24 time-weighted averages, null without data), `dom`,
|
||||
`yt` (axis values and their text, from `history_card.axis`), `dec`, `unit`, `hi` and `lo` ([value, unix time]).
|
||||
States (`kind: "timeline"`): `slots` (96), `states` ([words, colour, seconds], at most six and "Other"), `seg`
|
||||
([slot, legend index or -1, begin, end, seconds], the real times of the run's state in seconds after `start`),
|
||||
`words` ([raw state, words] for the heading), `began` and `active`. Both carry `start`, `end`, `off` (the UTC offset
|
||||
at `end`) and `xt` (round times for the time axis). The largest possible timeline is about 3.6 KB.
|
||||
- Data: a day or a week from `recorder/statistics_during_period` (hourly mean, min and max, or state for a total),
|
||||
starting an hour before the range; an hour, an entity without statistics and every timeline from
|
||||
`/api/history/period` with `minimal_response` and `no_attributes` (the call the tile graphs already use, capped at
|
||||
2 MB). Answers are cached per entity and range for 30 s, 2 min or 10 min, and screens asking at once share one
|
||||
fetch. A fetch that fails sends and keeps nothing.
|
||||
- The screen keeps only the answer it waits for (`history_asked_entity`, `history_asked_hours`), draws the line
|
||||
as a monotone cubic through the averages and through the highest and lowest moment (`history_view::monotone`,
|
||||
`through`), and the timeline as rectangles in one draw event. The value now joins the highest or lowest moment
|
||||
when it lies beyond the history. A transparent area with `LV_OBJ_FLAG_PRESS_LOCK` reads PRESSED and PRESSING
|
||||
and only changes the heading's labels; nothing on the graph moves. Range keys are detail commands 160-162 left
|
||||
out of `detail_actions`, so a command waiting on Home Assistant does not disable them.
|
||||
- `runtime_tiles::small_font` (axis labels and legend) is set to `sublabel` in `on_boot` of both profiles. The
|
||||
tile option `history_hours` still sets the tile's graph; a card opens on 1 hour when it is 1, else on 24 hours.
|
||||
- Host redraw of the line card is about 70 ms, like the cover and weather cards (fill about 6 ms, line about 12 ms).
|
||||
- `render_weather_detail` starts at y 84 on the Guition (was 62, from before the round back button of 0.2.43, which
|
||||
reaches y 76). The CYD keeps 38: its days card has no room to spare, and its back button overlaps the card by 10 px
|
||||
as before.
|
||||
|
||||
### Compatibility 0.2.58 / firmware 0.2.50
|
||||
|
||||
App, both board profiles and `components/smart_display`. Storage, tile protocol, preferences and keys are
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Test results app 0.2.59 / firmware 0.2.51 (2026-09-16)
|
||||
|
||||
Max found the history card unusable ("no y axis, no real x axis") and chose option A of two designs: a line with
|
||||
axes for numbers, with the touch readout of option B (a finger on the graph shows the value and time of that
|
||||
moment). History for on/off, people and numbers too, fetched when the card opens, for 1 hour, 24 hours or a week
|
||||
with the same number of points. During the work he asked for three changes, all in this release: the graph stays
|
||||
as it is under a finger (only the words at the top change), the rings of the highest and lowest moment sit on the
|
||||
line's peak and valley, and a sensor that only exists for two hours shows no data before that. See CHANGELOG
|
||||
0.2.59 and the compatibility note in docs/RELEASING.md.
|
||||
|
||||
## Automated
|
||||
|
||||
- Python (`.venv-portal`): 282 tests OK. New `tests/test_history_card.py` (28): which entities get a line or a
|
||||
timeline; time-weighted averages with gaps; statistics rows (milliseconds, gaps, min and max); the highest and
|
||||
lowest moment; axes in round steps for °C, °F, %, W, kWh, negative values, lux, m³/h, ppm, V and a flat line;
|
||||
decimals from the display precision; time ticks every quarter hour, six hours or midnight, across a daylight
|
||||
saving change; a door's timeline with Home Assistant's words, runs with the real times of their state (an
|
||||
opening of 5 minutes in a 15-minute slot reads 01:00-01:05, 300 s), people with zones and their own colours,
|
||||
statuses beyond six states joining "Other"; a sensor two hours old (averages only for its own hours, also from
|
||||
statistics with one compiled hour, and a sensor with one state) and a binary sensor two hours old (no data before
|
||||
it); a motion sensor that changes every 30 s for a week (20,160 changes: 13 ms, 354 bytes); the largest possible
|
||||
timeline (96 runs, 32-character states) at 3.6 KB of the 4096 a screen takes. The manager: a number and a door
|
||||
each get one message through the screen's action, a day from hourly statistics and an hour from the changes;
|
||||
screens asking at once share one fetch and a reopened card uses the cache; a failed fetch sends and keeps nothing
|
||||
and the next question is answered; entities that are not on the screen, other inboxes, other ranges, a light and
|
||||
an offline screen get nothing; the event loop survives a failure. Firmware source checks: the event, the answer
|
||||
it waits for, the entities with a history card, range keys kept out of the disabled keys, the press lock, and
|
||||
`small_font` in both profiles and packages.
|
||||
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 15/15 PASS. New
|
||||
`test_history_view`: clock times in 24 and 12 hours with weekdays and negative offsets, axis times ("4 PM",
|
||||
"4:15 PM"), durations, numbers with thousands and glued units, the part and the run under a finger, the monotone
|
||||
line (a setpoint stepping 55-45-60 stays between 45 and 60, flat stays flat, every point on the line, a small
|
||||
buffer is not overrun) and `through` (a moment takes its part's point, a second moment in the same part joins
|
||||
beside it, a full buffer refuses).
|
||||
- `generate_packages.py --check` green.
|
||||
|
||||
## Builds (ESPHome 2026.6.2)
|
||||
|
||||
Check profiles with the generated packages and local components, the same as for 0.2.58.
|
||||
|
||||
| Profile | RAM | Flash | vs 0.2.58 |
|
||||
|---|---|---|---|
|
||||
| CYD | 23.5% (77,060 B) | 77.8% (1,427,015 B) | +512 B RAM, +15,192 B flash |
|
||||
| Guition | 24.5% (80,436 B) | 18.8% (1,530,331 B) | +512 B RAM, +15,068 B flash |
|
||||
|
||||
No warnings from `runtime_tiles.h` or `history_view.h` (the switch's part/state selector, moved from the old switch
|
||||
card, now casts to `lv_style_selector_t`).
|
||||
|
||||
## Firmware on the Mac (host builds, driven over the ESPHome API)
|
||||
|
||||
A demo home with a week of history (door, coffee machine, a person with zones, a washing machine status, a boiler
|
||||
setpoint, and a plug added two hours ago) answers with the real `Manager.card_history`; a virtual LVGL pointer
|
||||
drives the card. Guition 40/40, CYD 40/40:
|
||||
|
||||
- Opening a card fires one `esphome.screen_history` event (inbox, entity, 24); the card shows the value now and
|
||||
"Loading history..." until the answer, then the line with High and Low and their times and the axis labels the
|
||||
message carries.
|
||||
- A finger reads the average of the hour under it with its time range and "average of 1 h" in the accent colour;
|
||||
dragging moves the readout, past the edge it holds the last hour, lifting restores the header. The objects of the
|
||||
graph (count, positions, visibility) are identical before and under the finger, on the line and the timeline.
|
||||
- "1 week" asks for 168 hours and waits with the week selected; a late answer for the day is ignored; the week's
|
||||
High and Low name the weekday and a week part reads as a weekday time range averaged over 7 h. "1 hour" asks for
|
||||
1 hour and a part reads "average"; tapping the range shown asks nothing; a new state updates the header without a
|
||||
new question; reopening starts on 24 hours and asks again.
|
||||
- The door: the heading follows the state ("Off" before the answer, "Closed" with it, "Open" when the door opens
|
||||
while the card is open), "Open · 22 min" and "6 times"; a finger on the opening of 12:30 reads "12:30 – 12:35"
|
||||
and "5 min", the unavailable stretch "14:00 – 14:20" and "20 min".
|
||||
- A person with zones (home, "once"), the week; a washing machine status without counts; the coffee machine with its
|
||||
toggle, which sends `switch.turn_off`; the boiler setpoint with its slider.
|
||||
- The plug added two hours ago: its line only over its last three parts, High 1,850 W and Low; a finger before it
|
||||
existed reads "No data" with the time, on its last hour its average.
|
||||
- Without an answer "No history available" after 8 s, and a late answer is still drawn; an empty hour says
|
||||
"No history in this period"; malformed history messages change nothing; no errors in the log.
|
||||
- Full redraws on the host: the line card about 70 ms like the cover, weather and vacuum cards (the fill about
|
||||
6 ms, the line about 12 ms). The host's LVGL loop stalls for 1-2 s at random, on any card; the checks wait for
|
||||
the state they expect instead of fixed pauses.
|
||||
- Renders of every card on both boards; the README shows the new history cards. All README screens were rendered
|
||||
again from the standard demo home: the pages and the alert are unchanged, the light card and both weather cards
|
||||
changed since 0.2.41 and were replaced. The Guition weather card's round back button lay half under the card
|
||||
(since 0.2.43); it now starts below the button, checked in a new render. On the CYD moving it squeezed the days
|
||||
too much, so the CYD keeps its layout. A playing media player's volume slider is grey on the tile since 0.2.43
|
||||
(Home Assistant colours it); left for a separate change.
|
||||
|
||||
## Not tested / for the owner
|
||||
|
||||
- A live read-only check against Home Assistant could not run: both local tokens were refused. The card uses the
|
||||
same two calls the tile graphs already use (`recorder/statistics_during_period`, now with min and max, and
|
||||
`/api/history/period`).
|
||||
- Real hardware: on Studio 1 after the update, open a temperature, a door or a person, switch between 1 hour,
|
||||
24 hours and 1 week, and slide a finger over the graph. Free heap on a CYD with a week's timeline open.
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -2,7 +2,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.51"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -182,6 +182,7 @@ esphome:
|
||||
runtime_tiles::screen_awake = []() { return !id(display_dimmed); };
|
||||
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||||
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
|
||||
runtime_tiles::small_font = id(sublabel)->get_lv_font();
|
||||
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
|
||||
// label lends its margin and drawing order and stays hidden in runtime mode.
|
||||
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.51"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
@@ -294,6 +294,7 @@ esphome:
|
||||
runtime_tiles::screen_awake = []() { return !id(display_dimmed); };
|
||||
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||||
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
|
||||
runtime_tiles::small_font = id(sublabel)->get_lv_font();
|
||||
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
|
||||
// label lends its margin and drawing order and stays hidden in runtime mode.
|
||||
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.51"
|
||||
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
|
||||
TOUCH_MOVE_LIMIT_PX: "56"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
@@ -298,6 +298,7 @@ esphome:
|
||||
runtime_tiles::screen_awake = []() { return !id(display_dimmed); };
|
||||
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||||
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
|
||||
runtime_tiles::small_font = id(sublabel)->get_lv_font();
|
||||
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
|
||||
// label lends its margin and drawing order and stays hidden in runtime mode.
|
||||
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
|
||||
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.50"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.51"
|
||||
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
|
||||
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
@@ -186,6 +186,7 @@ esphome:
|
||||
runtime_tiles::screen_awake = []() { return !id(display_dimmed); };
|
||||
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
|
||||
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
|
||||
runtime_tiles::small_font = id(sublabel)->get_lv_font();
|
||||
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
|
||||
// label lends its margin and drawing order and stays hidden in runtime mode.
|
||||
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## 0.2.59 (firmware 0.2.51)
|
||||
|
||||
A history card you can read: axes, an hour, a day or a week, and the value under your finger.
|
||||
|
||||
- **History with axes.** Tapping a sensor, a number, a binary sensor or a person, or holding a switch, opens its history the way Home Assistant shows it. Numbers get a line with round values along the side and clock times along the bottom, and their highest and lowest moment as rings with their values. On/off, home and away, zones and a status like a washing machine's get a timeline with the time spent in each state. The value now stays big at the top, with the highest and lowest moment and their times beside it, or how long the state lasted and how often it began: "Open · 22 min, 6 times".
|
||||
- **An hour, a day or a week.** Three keys below the graph. Every range has the same number of points, just further apart: 24 averages on a line, 96 steps on a timeline. ESP Screens fetches the range when the card opens, from Home Assistant's statistics where the entity has them.
|
||||
- **Your finger reads the graph.** Hold a finger on the graph and slide: the top of the card shows the value and the time of that moment. On a timeline it shows the state, when it began and ended, and how long it lasted, so a door that stood open for five minutes says 5 min. The graph itself stays as it is, and letting go shows the value now again.
|
||||
- **Every unit.** Temperatures, percentages, watts, kilowatt-hours, lux and any other unit get an axis in round steps with the decimals the entity shows in Home Assistant. Times follow the screen's 12- or 24-hour clock.
|
||||
- A switch's card keeps its toggle, top right, and a number's card its slider.
|
||||
- **The weather card's back button is whole again on the Guition.** Since 0.2.43 the round back button sat half under the card with the current weather.
|
||||
- Needs firmware 0.2.51: press **Update** on the screen. Screens with older firmware keep their old card.
|
||||
|
||||
## 0.2.58 (firmware 0.2.50)
|
||||
|
||||
A card for blinds, curtains and garage doors, and three fixes found right after updating to 0.2.57.
|
||||
|
||||
@@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
# Firmware shipped with this app release; screens below it get an update offer.
|
||||
FIRMWARE_VERSION = '0.2.50'
|
||||
FIRMWARE_VERSION = '0.2.51'
|
||||
# The Auto standby switch a screen offers Home Assistant automations.
|
||||
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
|
||||
# The settings page the screen opens itself, and the screen.settings tile that opens it.
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""History on a screen's detail card (app 0.2.59 / firmware 0.2.51).
|
||||
|
||||
A card asks for it when it opens or when its range changes: the screen fires `esphome.screen_history` with its
|
||||
inbox, the entity and the hours (1, 24 or 168), and the manager answers with one `history` message. Numbers get a
|
||||
line of 24 points, averaged over equal parts of the range, with their highest and lowest moment and an axis in
|
||||
round steps; states (on/off, home/away, a status sensor) get a timeline of at most 96 slots with the time spent
|
||||
in each state, as Home Assistant's own history does. The screen draws it and formats the times itself, so its
|
||||
12- or 24-hour clock applies.
|
||||
"""
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import header_bar
|
||||
|
||||
RANGES = (1, 24, 168)
|
||||
POINTS = 24
|
||||
SLOTS = 96
|
||||
STATES_SHOWN = 6
|
||||
# Entities whose card shows history, by what they show.
|
||||
LINE_DOMAINS = frozenset(('number', 'input_number'))
|
||||
TIMELINE_DOMAINS = frozenset(('binary_sensor', 'switch', 'input_boolean', 'person'))
|
||||
# Sensors whose state is not a measurement.
|
||||
TEXT_SENSOR_CLASSES = frozenset(('timestamp', 'date', 'enum'))
|
||||
# The state that counts as "on" for the count of times it began.
|
||||
ACTIVE = {'binary_sensor': 'on', 'switch': 'on', 'input_boolean': 'on', 'person': 'home'}
|
||||
OFF_GREY, NO_DATA = 'CFCFCF', 'E6E6E6'
|
||||
PALETTE = ('2196F3', '009688', 'FF6F22', '8E24AA', '43A047', 'E53935')
|
||||
# How long an answer stays good: a card reopened within this time gets it without asking Home Assistant again.
|
||||
CACHE_SECONDS = {1: 30, 24: 120, 168: 600}
|
||||
|
||||
|
||||
def kind(entity, state):
|
||||
"""'line', 'timeline', or None for an entity whose card has no history."""
|
||||
domain = entity.split('.')[0]
|
||||
if domain in LINE_DOMAINS:
|
||||
return 'line'
|
||||
if domain in TIMELINE_DOMAINS:
|
||||
return 'timeline'
|
||||
if domain != 'sensor':
|
||||
return None
|
||||
attrs = (state or {}).get('attributes') or {}
|
||||
if attrs.get('device_class') in TEXT_SENSOR_CLASSES:
|
||||
return 'timeline'
|
||||
if attrs.get('unit_of_measurement') or attrs.get('state_class'):
|
||||
return 'line'
|
||||
return 'line' if header_bar.numeric((state or {}).get('state')) is not None else 'timeline'
|
||||
|
||||
|
||||
def window(hours, now=None):
|
||||
"""(start, end) unix times of the range that ends now, on whole seconds."""
|
||||
end = int((now or datetime.now(timezone.utc)).timestamp())
|
||||
return end - hours * 3600, end
|
||||
|
||||
|
||||
def bucket_means(changes, start, end, points=POINTS):
|
||||
"""Time-weighted mean per equal part of [start, end) of a value that holds from each change to the next.
|
||||
|
||||
`changes` are (unix time, value or None); None marks a time without a value (unavailable, a gap between
|
||||
statistics). A part without any known value is None."""
|
||||
changes = sorted(changes, key=lambda change: change[0])
|
||||
span = (end - start) / points
|
||||
out, index, current = [], 0, None
|
||||
while index < len(changes) and changes[index][0] <= start:
|
||||
current = changes[index][1]
|
||||
index += 1
|
||||
for part in range(points):
|
||||
begin, finish = start + part * span, start + (part + 1) * span
|
||||
moment, area, known = begin, 0.0, 0.0
|
||||
while index < len(changes) and changes[index][0] < finish:
|
||||
at, value = changes[index]
|
||||
if current is not None:
|
||||
area += current * (at - moment)
|
||||
known += at - moment
|
||||
moment, current = max(at, begin), value
|
||||
index += 1
|
||||
if current is not None:
|
||||
area += current * (finish - moment)
|
||||
known += finish - moment
|
||||
out.append(area / known if known > 0 else None)
|
||||
return out
|
||||
|
||||
|
||||
def extremes(changes, start, end):
|
||||
"""((highest, when), (lowest, when)) of the values in the range, a value from before it counting at `start`."""
|
||||
changes = sorted(changes, key=lambda change: change[0])
|
||||
seen, current = [], None
|
||||
for at, value in changes:
|
||||
if at <= start:
|
||||
current = value
|
||||
continue
|
||||
if at >= end:
|
||||
break
|
||||
if current is not None and not seen:
|
||||
seen.append((current, start))
|
||||
if value is not None:
|
||||
seen.append((value, at))
|
||||
current = value
|
||||
if not seen and current is not None:
|
||||
seen.append((current, start))
|
||||
if not seen:
|
||||
return None, None
|
||||
return max(seen, key=lambda item: item[0]), min(seen, key=lambda item: item[0])
|
||||
|
||||
|
||||
def nice_step(span, target=3):
|
||||
"""A round step (1, 2, 2.5 or 5 times a power of ten) that divides `span` into about `target` parts."""
|
||||
raw = span / target
|
||||
magnitude = 10 ** math.floor(math.log10(raw))
|
||||
for multiple in (1, 2, 2.5, 5, 10):
|
||||
if multiple * magnitude >= raw * 0.999:
|
||||
return multiple * magnitude
|
||||
return 10 * magnitude
|
||||
|
||||
|
||||
def axis(low, high):
|
||||
"""(domain, ticks): the drawn range and the round values labelled on it. A flat line gets room around it."""
|
||||
if high - low < 1e-9:
|
||||
pad = abs(high) * 0.05 or 1.0
|
||||
low, high = low - pad, high + pad
|
||||
step = nice_step(high - low)
|
||||
ticks = []
|
||||
value = math.ceil(low / step - 1e-9) * step
|
||||
while value <= high + step * 1e-9:
|
||||
ticks.append(round(value, 10))
|
||||
value += step
|
||||
if len(ticks) < 2:
|
||||
ticks = [math.floor(low / step) * step, math.ceil(high / step) * step]
|
||||
low, high = min(low, ticks[0]), max(high, ticks[-1])
|
||||
margin = (high - low) * 0.06
|
||||
return (low - margin, high + margin), ticks
|
||||
|
||||
|
||||
def step_decimals(step):
|
||||
"""The decimals a step needs to be written exactly: 0.5 needs one, 0.25 two, 2.5 one, 20 none."""
|
||||
for places in range(4):
|
||||
if abs(round(step, places) - step) <= abs(step) * 1e-6:
|
||||
return places
|
||||
return 3
|
||||
|
||||
|
||||
def tick_text(value, step, unit):
|
||||
"""An axis label: as few decimals as the step needs; degrees and percent stay on the number."""
|
||||
text = header_bar.number_text(value, step_decimals(step))
|
||||
if unit in ('°C', '°F', '°'):
|
||||
return text + '°'
|
||||
return text + '%' if unit == '%' else text
|
||||
|
||||
|
||||
def decimals(entry, values):
|
||||
"""Decimals for a value read from the line: the entity's display precision, else one for averages."""
|
||||
precision = header_bar.precision_of(entry)
|
||||
if precision is not None:
|
||||
return precision
|
||||
return 0 if all(value is None or float(value).is_integer() for value in values) else 1
|
||||
|
||||
|
||||
def time_ticks(start, end, hours, tz):
|
||||
"""Unix times of round moments in the range: every quarter hour, every six hours, or every midnight."""
|
||||
tz = tz or timezone.utc
|
||||
step = {1: timedelta(minutes=15), 24: timedelta(hours=6), 168: timedelta(days=1)}[hours]
|
||||
moment = datetime.fromtimestamp(start, tz)
|
||||
if hours == 1:
|
||||
moment = moment.replace(minute=moment.minute - moment.minute % 15, second=0, microsecond=0)
|
||||
elif hours == 24:
|
||||
moment = moment.replace(hour=moment.hour - moment.hour % 6, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
moment = moment.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
ticks = []
|
||||
# A tick right at either end would sit on the start of the line or on "Now"; the screen drops a label that
|
||||
# still collides.
|
||||
margin = (end - start) * 0.03
|
||||
while moment.timestamp() < end:
|
||||
at = moment.timestamp()
|
||||
if start + margin <= at <= end - margin:
|
||||
ticks.append(int(at))
|
||||
# Adding in local time keeps midnight on midnight across a daylight saving change.
|
||||
moment = (moment.replace(tzinfo=None) + step).replace(tzinfo=tz)
|
||||
return ticks
|
||||
|
||||
|
||||
def utc_offset(end, tz):
|
||||
return int(((tz or timezone.utc).utcoffset(datetime.fromtimestamp(end, tz or timezone.utc)) or timedelta()).total_seconds())
|
||||
|
||||
|
||||
def line(entity, hours, changes, start, end, tz, entry=None, unit='', extreme_changes=None):
|
||||
"""The `history` message for a number: 24 averages, the highest and lowest moment, the axis and the times.
|
||||
|
||||
`changes` feed the averages; `extreme_changes` (statistics' own highs and lows, or the same changes) the
|
||||
highest and lowest moment."""
|
||||
values = bucket_means(changes, start, end)
|
||||
high, low = extremes(extreme_changes if extreme_changes is not None else changes, start, end)
|
||||
known = [value for value in values if value is not None] + [item[0] for item in (high, low) if item]
|
||||
message = {'v': 1, 'op': 'history', 'entity': entity, 'hours': hours, 'kind': 'line', 'start': start, 'end': end,
|
||||
'off': utc_offset(end, tz), 'xt': time_ticks(start, end, hours, tz), 'unit': header_bar.clean_text(unit or '')[:12]}
|
||||
if not known:
|
||||
message['values'] = [None] * POINTS
|
||||
return message
|
||||
(bottom, top), ticks = axis(min(known), max(known))
|
||||
step = ticks[1] - ticks[0] if len(ticks) > 1 else 1
|
||||
places = decimals(entry, known)
|
||||
message.update({
|
||||
'values': [None if value is None else round(value, max(places, 2)) for value in values],
|
||||
'dom': [round(bottom, 6), round(top, 6)],
|
||||
'yt': [[round(tick, 6), tick_text(tick, step, unit)] for tick in ticks],
|
||||
'dec': places,
|
||||
})
|
||||
if high:
|
||||
message['hi'] = [round(high[0], max(places, 2)), int(high[1])]
|
||||
if low:
|
||||
message['lo'] = [round(low[0], max(places, 2)), int(low[1])]
|
||||
return message
|
||||
|
||||
|
||||
def state_label(domain, state, attrs):
|
||||
"""Home Assistant's words for a state, as the screen shows them elsewhere."""
|
||||
if state in (None, 'unavailable'):
|
||||
return 'Unavailable'
|
||||
if state == 'unknown':
|
||||
return 'Unknown'
|
||||
if domain == 'binary_sensor':
|
||||
pair = header_bar.BINARY_STATES.get((attrs or {}).get('device_class'), ('On', 'Off'))
|
||||
return pair[0] if state == 'on' else pair[1] if state == 'off' else state
|
||||
if domain == 'person' and state not in header_bar.STATES:
|
||||
return state.replace('_', ' ')[:1].upper() + state.replace('_', ' ')[1:]
|
||||
return header_bar.STATES.get(state) or (state[:1].upper() + state[1:]).replace('_', ' ')
|
||||
|
||||
|
||||
def state_color(domain, state, others):
|
||||
"""A state's colour: Home Assistant's amber for on and green for home, grey for off and away; every other state
|
||||
(a zone, a status) the next colour of the palette, `others` being how many such states came before it."""
|
||||
if state in (None, 'unavailable', 'unknown'):
|
||||
return NO_DATA
|
||||
if domain in ('binary_sensor', 'switch', 'input_boolean'):
|
||||
return header_bar.AMBER if state == 'on' else OFF_GREY
|
||||
if domain == 'person' and state in ('home', 'not_home'):
|
||||
return header_bar.GREEN if state == 'home' else OFF_GREY
|
||||
return PALETTE[others % len(PALETTE)]
|
||||
|
||||
|
||||
def timeline(entity, hours, changes, start, end, tz, attrs=None, slots=SLOTS):
|
||||
"""The `history` message for states: the timeline as runs of slots, each state's time, and how often the
|
||||
active state began (a door opened, a person came home)."""
|
||||
domain = entity.split('.')[0]
|
||||
changes = sorted(changes, key=lambda change: change[0])
|
||||
# The state in force over each stretch of the range.
|
||||
pieces, current, index = [], None, 0
|
||||
while index < len(changes) and changes[index][0] <= start:
|
||||
current = changes[index][1]
|
||||
index += 1
|
||||
moment, began = start, 0
|
||||
active = ACTIVE.get(domain)
|
||||
for at, state in changes[index:]:
|
||||
if at >= end:
|
||||
break
|
||||
if state == current:
|
||||
continue
|
||||
pieces.append((moment, at, current))
|
||||
if active and state == active:
|
||||
began += 1
|
||||
moment, current = at, state
|
||||
pieces.append((moment, end, current))
|
||||
totals, order = {}, []
|
||||
for begin, finish, state in pieces:
|
||||
if state is None:
|
||||
continue
|
||||
if state not in totals:
|
||||
order.append(state)
|
||||
totals[state] = totals.get(state, 0) + finish - begin
|
||||
# The most time first; a state beyond the sixth joins "Other".
|
||||
ranked = sorted(order, key=lambda state: -totals[state])
|
||||
shown = ranked[:STATES_SHOWN] if len(ranked) <= STATES_SHOWN else ranked[:STATES_SHOWN - 1]
|
||||
index_of = {state: i for i, state in enumerate(shown)}
|
||||
others = [state for state in order if state_color(domain, state, 0) == PALETTE[0]]
|
||||
legend = [[header_bar.clean_text(state_label(domain, state, attrs))[:24],
|
||||
state_color(domain, state, others.index(state) if state in others else 0), int(totals[state])] for state in shown]
|
||||
rest = [state for state in ranked if state not in index_of]
|
||||
if rest:
|
||||
index_of.update({state: len(shown) for state in rest})
|
||||
legend.append(['Other', '9E9E9E', int(sum(totals[state] for state in rest))])
|
||||
# Each slot shows the state that covers most of it, except that the active state always shows: a door that
|
||||
# stood open for five minutes stays visible on a day's timeline, as it does in Home Assistant.
|
||||
span = (end - start) / slots
|
||||
runs, piece = [], 0
|
||||
for slot in range(slots):
|
||||
begin, finish = start + slot * span, start + (slot + 1) * span
|
||||
cover = {}
|
||||
while piece < len(pieces) and pieces[piece][1] <= begin:
|
||||
piece += 1
|
||||
look = piece
|
||||
while look < len(pieces) and pieces[look][0] < finish:
|
||||
p_begin, p_end, state = pieces[look]
|
||||
if state is not None:
|
||||
cover[state] = cover.get(state, 0) + min(p_end, finish) - max(p_begin, begin)
|
||||
look += 1
|
||||
winner = active if active in cover else max(cover, key=cover.get) if cover else None
|
||||
value = index_of[winner] if winner is not None else -1
|
||||
if not runs or runs[-1][1] != value:
|
||||
runs.append([slot, value])
|
||||
# Which legend entry is the active state (the card sums up its time and how often it began), or -1.
|
||||
shown_active = index_of.get(active, -1) if active in shown else -1
|
||||
return {'v': 1, 'op': 'history', 'entity': entity, 'hours': hours, 'kind': 'timeline', 'start': start, 'end': end,
|
||||
'off': utc_offset(end, tz), 'xt': time_ticks(start, end, hours, tz), 'slots': slots, 'states': legend,
|
||||
'seg': run_times(runs, pieces, index_of, start, span, slots), 'words': words(domain, order + [current], attrs),
|
||||
'began': began, 'active': shown_active}
|
||||
|
||||
|
||||
def run_times(runs, pieces, index_of, start, span, slots):
|
||||
"""[slot, state, begin, end, seconds] per run: a finger on a run reads the real times of its state, from its first
|
||||
moment to its last (seconds after `start`), and the time spent in it, not the width of the slots it covers."""
|
||||
out, first = [], 0
|
||||
for i, (slot, value) in enumerate(runs):
|
||||
low, high = start + slot * span, start + (runs[i + 1][0] if i + 1 < len(runs) else slots) * span
|
||||
while first < len(pieces) and pieces[first][1] <= low:
|
||||
first += 1
|
||||
found, look = [], first
|
||||
while look < len(pieces) and pieces[look][0] < high:
|
||||
begin, finish, state = pieces[look]
|
||||
if value >= 0 and state is not None and index_of.get(state) == value:
|
||||
found.append((begin, finish))
|
||||
look += 1
|
||||
if found:
|
||||
begin, finish, seconds = min(b for b, _ in found), max(f for _, f in found), sum(f - b for b, f in found)
|
||||
else:
|
||||
begin, finish, seconds = low, high, high - low
|
||||
out.append([slot, value, round(begin - start), round(finish - start), round(seconds)])
|
||||
return out
|
||||
|
||||
|
||||
def words(domain, states, attrs):
|
||||
"""[raw state, words] for the states of the range and a binary sensor's on and off: the card's heading shows the
|
||||
state now in these words, also after it changed while the card is open."""
|
||||
found = {}
|
||||
for state in states + (['on', 'off'] if domain == 'binary_sensor' else []):
|
||||
if isinstance(state, str) and len(state) <= 32 and state not in found and len(found) < 10:
|
||||
found[state] = header_bar.clean_text(state_label(domain, state, attrs))[:24]
|
||||
return [[state, text] for state, text in found.items()]
|
||||
|
||||
|
||||
def statistic_changes(rows, period_seconds):
|
||||
"""Statistics rows as changes for `bucket_means` (each mean holds for its period, a gap has no value) and as
|
||||
changes for `extremes` (each period's own maximum and minimum)."""
|
||||
means, extreme = [], []
|
||||
rows = sorted((row for row in rows if isinstance(row.get('start'), (int, float))), key=lambda row: row['start'])
|
||||
for i, row in enumerate(rows):
|
||||
start = row['start'] / 1000 if row['start'] > 1e11 else row['start']
|
||||
mean = header_bar.numeric(row.get('mean') if row.get('mean') is not None else row.get('state'))
|
||||
means.append((start, mean))
|
||||
following = rows[i + 1]['start'] if i + 1 < len(rows) else None
|
||||
following = following / 1000 if following and following > 1e11 else following
|
||||
if following is None or following - start > period_seconds + 1:
|
||||
means.append((start + period_seconds, None))
|
||||
for key in ('max', 'min'):
|
||||
value = header_bar.numeric(row.get(key))
|
||||
if value is not None:
|
||||
extreme.append((start, value))
|
||||
if mean is not None and header_bar.numeric(row.get('max')) is None:
|
||||
extreme.append((start, mean))
|
||||
return means, extreme
|
||||
@@ -15,10 +15,11 @@ from firmware import Firmware
|
||||
import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
|
||||
import header_bar
|
||||
import history_card
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
@@ -104,6 +105,8 @@ class HomeAssistant:
|
||||
# Assistant's own list; refreshed when a device registers its actions again.
|
||||
self.responses = set()
|
||||
self.services_changed = asyncio.Event()
|
||||
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
|
||||
self.history_requests = asyncio.Queue()
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
@@ -137,6 +140,8 @@ class HomeAssistant:
|
||||
if event.get('event_type') == 'esphome.screen_setting':
|
||||
self.setting_events.append(body)
|
||||
self.changed.set()
|
||||
elif event.get('event_type') == 'esphome.screen_history':
|
||||
self.history_requests.put_nowait(body)
|
||||
elif event.get('event_type') == 'state_changed':
|
||||
eid = body.get('entity_id')
|
||||
if body.get('new_state'):
|
||||
@@ -199,6 +204,7 @@ class HomeAssistant:
|
||||
reader = asyncio.create_task(self.read())
|
||||
await self.request('subscribe_events', event_type='state_changed')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_history')
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
|
||||
await self.request('subscribe_events', event_type=event_type)
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
@@ -324,7 +330,8 @@ class HomeAssistant:
|
||||
found[entity] = samples(events, begin, span)
|
||||
return found
|
||||
|
||||
async def history(self, entity, hours):
|
||||
async def state_changes(self, entity, hours):
|
||||
"""(unix time, state) of every change in the last `hours`, beginning with the state at the start (REST history)."""
|
||||
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
|
||||
async with self.session.get(self.base+'/history/period/'+start,
|
||||
params={'filter_entity_id':entity,'minimal_response':'','no_attributes':''},
|
||||
@@ -335,14 +342,26 @@ class HomeAssistant:
|
||||
raw.extend(chunk)
|
||||
if len(raw)>2*1024*1024: raise ValueError('History too large.')
|
||||
rows=json.loads(raw)
|
||||
events=[]
|
||||
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
|
||||
changes=[]
|
||||
for row in rows[0] if rows else []:
|
||||
try:
|
||||
timestamp=datetime.fromisoformat(row.get('last_changed',row.get('last_updated','')).replace('Z','+00:00')).timestamp()
|
||||
except (ValueError,TypeError): continue
|
||||
events.append((timestamp,rounded(row.get('state'))))
|
||||
return samples(events, begin, span)
|
||||
except (ValueError,TypeError,AttributeError): continue
|
||||
changes.append((timestamp,row.get('state')))
|
||||
return changes
|
||||
|
||||
async def history(self, entity, hours):
|
||||
begin=(datetime.now(timezone.utc)-timedelta(hours=hours)).timestamp()
|
||||
return samples([(timestamp,rounded(state)) for timestamp,state in await self.state_changes(entity,hours)], begin, hours*3600)
|
||||
|
||||
async def statistic_rows(self, entity, hours):
|
||||
"""The recorder's hourly statistics rows of one entity over the last `hours` (mean, min, max, state), from the
|
||||
hour the range begins in; empty for an entity without statistics."""
|
||||
start = datetime.now(timezone.utc) - timedelta(hours=hours + 1)
|
||||
result = await self.request('recorder/statistics_during_period', start_time=start.isoformat(), statistic_ids=[entity],
|
||||
period='hour', types=['mean', 'min', 'max', 'state'])
|
||||
rows = (result or {}).get(entity) if isinstance(result, dict) else None
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
class Manager:
|
||||
def __init__(self, ha, path):
|
||||
@@ -374,6 +393,8 @@ class Manager:
|
||||
# Answers (firmware 0.2.49+): when to send everything again after one said the screen lacks something,
|
||||
# how often that failed in a row, and actions Home Assistant refused to answer for.
|
||||
self.retry_at, self.retries, self.no_answers = {}, {}, set()
|
||||
# History for detail cards (firmware 0.2.51+): (entity, hours) -> (monotonic, message), and the fetches under way.
|
||||
self.card_histories, self.card_history_fetches = {}, {}
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -652,6 +673,78 @@ class Manager:
|
||||
self.retries[inbox] = failures + 1
|
||||
self.retry_at[inbox] = due
|
||||
|
||||
# ----- History on a detail card (firmware 0.2.51+) -----
|
||||
async def card_history_loop(self):
|
||||
"""Answer the history a screen asks for when a card opens, a few at a time."""
|
||||
limit = asyncio.Semaphore(3)
|
||||
|
||||
async def answer(request):
|
||||
async with limit:
|
||||
try:
|
||||
await self.answer_history(request)
|
||||
except (ClientError, ConnectionError, TimeoutError, OSError, ValueError) as error:
|
||||
# Nothing is sent or kept: the card says it has no history and asks again.
|
||||
LOG.info('No history for %s (%s)', request.get('entity'), type(error).__name__)
|
||||
except Exception as error:
|
||||
LOG.warning('History for %s was not sent (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
|
||||
while True:
|
||||
request = await self.ha.history_requests.get()
|
||||
asyncio.ensure_future(answer(request))
|
||||
|
||||
async def answer_history(self, request):
|
||||
"""One screen's request: an entity on its own layout, a range the card offers, a screen that takes whole messages."""
|
||||
if not isinstance(request, dict):
|
||||
return
|
||||
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
|
||||
entity = request.get('entity')
|
||||
try:
|
||||
hours = int(request.get('hours'))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
layout, screen = self.layouts.get(inbox), self.screen(inbox) if isinstance(inbox, str) else None
|
||||
if hours not in history_card.RANGES or not layout or not screen or not screen.get('online'):
|
||||
return
|
||||
if entity not in {tile['entity'] for tile in layout['tiles']}:
|
||||
return
|
||||
what = history_card.kind(entity, self.ha.states.get(entity))
|
||||
action = self.transport(inbox, screen)
|
||||
if what is None or not action:
|
||||
return
|
||||
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
|
||||
|
||||
async def card_history(self, entity, hours, what):
|
||||
"""The history message, from a short cache; screens asking at the same time share one fetch."""
|
||||
key = (entity, hours)
|
||||
cached = self.card_histories.get(key)
|
||||
if cached and time.monotonic() - cached[0] < history_card.CACHE_SECONDS[hours]:
|
||||
return cached[1]
|
||||
fetch = self.card_history_fetches.get(key)
|
||||
if fetch is None:
|
||||
fetch = asyncio.ensure_future(self.build_card_history(entity, hours, what))
|
||||
self.card_history_fetches[key] = fetch
|
||||
fetch.add_done_callback(lambda _: self.card_history_fetches.pop(key, None))
|
||||
message = await asyncio.shield(fetch)
|
||||
self.card_histories[key] = (time.monotonic(), message)
|
||||
for old in [k for k, (moment, _) in self.card_histories.items() if time.monotonic() - moment > 3600]:
|
||||
del self.card_histories[old]
|
||||
return message
|
||||
|
||||
async def build_card_history(self, entity, hours, what):
|
||||
"""Numbers from the recorder's hourly statistics for a day or a week (the exact changes for an hour, or for an
|
||||
entity without statistics); states from their changes. A fetch that fails raises, so nothing is kept."""
|
||||
start, end = history_card.window(hours)
|
||||
tz = getattr(self.ha, 'time_zone', None)
|
||||
attrs = (self.ha.states.get(entity) or {}).get('attributes') or {}
|
||||
if what == 'timeline':
|
||||
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs)
|
||||
entry, unit = self.registry_index().get(entity), attrs.get('unit_of_measurement') or ''
|
||||
rows = await self.ha.statistic_rows(entity, hours) if hours > 1 else []
|
||||
if rows:
|
||||
means, extreme = history_card.statistic_changes(rows, 3600)
|
||||
return history_card.line(entity, hours, means, start, end, tz, entry, unit, extreme)
|
||||
changes = [(moment, header_bar.numeric(value)) for moment, value in await self.ha.state_changes(entity, hours)]
|
||||
return history_card.line(entity, hours, changes, start, end, tz, entry, unit)
|
||||
|
||||
def registry_index(self):
|
||||
"""Entity registry by id (display precision, entity category); rebuilt only when HA delivers a new registry."""
|
||||
registry = getattr(self.ha, 'registry', [])
|
||||
@@ -1312,7 +1405,7 @@ async def main():
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
try:
|
||||
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
|
||||
manager.alert_loop(), manager.tile_loop())
|
||||
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.58"
|
||||
version: "0.2.59"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""History on a detail card (app 0.2.59 / firmware 0.2.51): what the manager computes for the screen to draw.
|
||||
|
||||
Numbers: 24 time-weighted averages, the highest and lowest moment, an axis in round steps for every kind of unit.
|
||||
States: a timeline in runs of slots with Home Assistant's words and the time in each state.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import history_card as hc # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
|
||||
AMSTERDAM = ZoneInfo('Europe/Amsterdam')
|
||||
|
||||
|
||||
def at(text, tz=AMSTERDAM):
|
||||
return int(datetime.fromisoformat(text).replace(tzinfo=tz).timestamp())
|
||||
|
||||
|
||||
class Kinds(unittest.TestCase):
|
||||
def test_numbers_draw_a_line_and_states_a_timeline(self):
|
||||
self.assertEqual(hc.kind('sensor.t', {'state': '21.4', 'attributes': {'unit_of_measurement': '°C'}}), 'line')
|
||||
self.assertEqual(hc.kind('sensor.count', {'state': '3', 'attributes': {}}), 'line')
|
||||
self.assertEqual(hc.kind('sensor.power', {'state': 'unavailable', 'attributes': {'state_class': 'measurement'}}), 'line')
|
||||
self.assertEqual(hc.kind('sensor.washer', {'state': 'running', 'attributes': {}}), 'timeline')
|
||||
self.assertEqual(hc.kind('sensor.mode', {'state': '2', 'attributes': {'device_class': 'enum'}}), 'timeline')
|
||||
self.assertEqual(hc.kind('sensor.last_seen', {'state': '2026-09-16T10:00:00+00:00', 'attributes': {'device_class': 'timestamp'}}), 'timeline')
|
||||
for entity in ('number.target', 'input_number.volume'):
|
||||
self.assertEqual(hc.kind(entity, {'state': '5'}), 'line')
|
||||
for entity in ('binary_sensor.door', 'switch.kettle', 'input_boolean.guests', 'person.sam'):
|
||||
self.assertEqual(hc.kind(entity, {'state': 'on'}), 'timeline')
|
||||
for entity in ('light.lamp', 'climate.living', 'cover.blind', 'vacuum.robot'):
|
||||
self.assertIsNone(hc.kind(entity, {'state': 'on'}))
|
||||
|
||||
|
||||
class Averages(unittest.TestCase):
|
||||
def test_each_part_is_the_time_weighted_mean(self):
|
||||
start, end = 0, 24 * 3600
|
||||
self.assertEqual(hc.bucket_means([(-100, 20.0)], start, end), [20.0] * 24)
|
||||
# 20 for the first half of the fourth hour, 22 for the rest of the day.
|
||||
means = hc.bucket_means([(-100, 20.0), (3 * 3600 + 1800, 22.0)], start, end)
|
||||
self.assertEqual(means[:3], [20.0] * 3)
|
||||
self.assertAlmostEqual(means[3], 21.0)
|
||||
self.assertEqual(means[4:], [22.0] * 20)
|
||||
|
||||
def test_unknown_time_does_not_count_and_a_part_without_values_is_none(self):
|
||||
start, end = 0, 24 * 3600
|
||||
means = hc.bucket_means([(3600, 10.0), (3600 + 900, None), (3600 + 2700, 30.0)], start, end)
|
||||
self.assertIsNone(means[0], 'nothing known before the first value')
|
||||
self.assertAlmostEqual(means[1], (10 * 900 + 30 * 900) / 1800)
|
||||
self.assertEqual(means[2], 30.0)
|
||||
|
||||
def test_statistics_rows_hold_for_their_period_and_gaps_stay_empty(self):
|
||||
base = at('2026-09-16T00:00:00')
|
||||
# Home Assistant sends milliseconds; the hour from 02:00 has no statistics.
|
||||
rows = [{'start': base * 1000, 'mean': 10.0, 'min': 9.0, 'max': 12.0}, {'start': (base + 3600) * 1000, 'mean': 20.0, 'min': 18.0, 'max': 25.0},
|
||||
{'start': (base + 3 * 3600) * 1000, 'mean': 30.0, 'min': 29.0, 'max': 31.0}]
|
||||
means, extreme = hc.statistic_changes(rows, 3600)
|
||||
values = hc.bucket_means(means, base, base + 24 * 3600)
|
||||
self.assertEqual(values[:5], [10.0, 20.0, None, 30.0, None], 'the missing hour and the time after the last row stay empty')
|
||||
high, low = hc.extremes(extreme, base, base + 24 * 3600)
|
||||
self.assertEqual((high, low), ((31.0, base + 10800), (9.0, base)))
|
||||
|
||||
def test_the_highest_and_lowest_moment(self):
|
||||
high, low = hc.extremes([(-50, 19.5), (100, 21.2), (200, 18.9), (300, None), (400, 20.0)], 0, 1000)
|
||||
self.assertEqual((high, low), ((21.2, 100), (18.9, 200)))
|
||||
self.assertEqual(hc.extremes([(-50, 19.5)], 0, 1000), ((19.5, 0), (19.5, 0)), 'a value from before the range counts at its start')
|
||||
self.assertEqual(hc.extremes([], 0, 1000), (None, None))
|
||||
|
||||
|
||||
class Axes(unittest.TestCase):
|
||||
def labels(self, low, high, unit):
|
||||
(bottom, top), ticks = hc.axis(low, high)
|
||||
self.assertLessEqual(bottom, low)
|
||||
self.assertGreaterEqual(top, high)
|
||||
self.assertTrue(all(bottom <= tick <= top for tick in ticks))
|
||||
self.assertGreaterEqual(len(ticks), 2)
|
||||
self.assertLessEqual(len(ticks), 5)
|
||||
return [hc.tick_text(tick, ticks[1] - ticks[0], unit) for tick in ticks]
|
||||
|
||||
def test_round_steps_for_every_kind_of_unit(self):
|
||||
self.assertEqual(self.labels(18.97, 21.21, '°C'), ['19°', '20°', '21°'])
|
||||
self.assertEqual(self.labels(64.2, 71.9, '°F'), ['65°', '70°'])
|
||||
self.assertEqual(self.labels(20.1, 21.4, '°C'), ['20.5°', '21.0°'], 'one number of decimals on the whole axis')
|
||||
self.assertEqual(self.labels(35, 62, '%'), ['40%', '50%', '60%'])
|
||||
self.assertEqual(self.labels(120, 2450, 'W'), ['1,000', '2,000'])
|
||||
self.assertEqual(self.labels(1520.3, 1534.9, 'kWh'), ['1,525', '1,530'])
|
||||
self.assertEqual(self.labels(-3.2, 4.1, '°C'), ['-2.5°', '0.0°', '2.5°'])
|
||||
self.assertEqual(self.labels(0, 65000, 'lx'), ['0', '25,000', '50,000'])
|
||||
self.assertEqual(self.labels(0.0012, 0.0041, 'm³/h'), ['0.002', '0.003', '0.004'])
|
||||
self.assertEqual(self.labels(412, 1850, 'ppm'), ['500', '1,000', '1,500'])
|
||||
self.assertEqual(self.labels(229.1, 233.4, 'V'), ['230', '232'])
|
||||
|
||||
def test_a_flat_line_gets_room_and_labels(self):
|
||||
self.assertEqual(len(self.labels(21.0, 21.0, '°C')), 3)
|
||||
self.assertEqual(self.labels(0, 0, ''), ['-1', '0', '1'])
|
||||
|
||||
def test_decimals_follow_the_display_precision(self):
|
||||
self.assertEqual(hc.decimals({'options': {'sensor': {'display_precision': 2}}}, [1.0]), 2)
|
||||
self.assertEqual(hc.decimals({'options': {'sensor': {'suggested_display_precision': 0}}}, [1.5]), 0)
|
||||
self.assertEqual(hc.decimals(None, [3.0, 4.0]), 0)
|
||||
self.assertEqual(hc.decimals(None, [3.0, 4.25]), 1)
|
||||
|
||||
|
||||
class Times(unittest.TestCase):
|
||||
def test_round_moments_in_the_screen_s_time_zone(self):
|
||||
end = at('2026-09-16T16:40:00')
|
||||
ticks = hc.time_ticks(end - 24 * 3600, end, 24, AMSTERDAM)
|
||||
self.assertEqual([datetime.fromtimestamp(t, AMSTERDAM).strftime('%H:%M') for t in ticks], ['18:00', '00:00', '06:00', '12:00'])
|
||||
ticks = hc.time_ticks(end - 3600, end, 1, AMSTERDAM)
|
||||
self.assertEqual([datetime.fromtimestamp(t, AMSTERDAM).strftime('%H:%M') for t in ticks], ['15:45', '16:00', '16:15', '16:30'])
|
||||
ticks = hc.time_ticks(end - 168 * 3600, end, 168, AMSTERDAM)
|
||||
self.assertEqual([datetime.fromtimestamp(t, AMSTERDAM).strftime('%a %H:%M') for t in ticks],
|
||||
['Thu 00:00', 'Fri 00:00', 'Sat 00:00', 'Sun 00:00', 'Mon 00:00', 'Tue 00:00', 'Wed 00:00'])
|
||||
self.assertEqual(hc.utc_offset(end, AMSTERDAM), 7200)
|
||||
|
||||
def test_midnight_stays_midnight_across_daylight_saving(self):
|
||||
end = at('2026-10-28T09:00:00')
|
||||
ticks = hc.time_ticks(end - 168 * 3600, end, 168, AMSTERDAM)
|
||||
self.assertTrue(all(datetime.fromtimestamp(t, AMSTERDAM).strftime('%H:%M') == '00:00' for t in ticks))
|
||||
self.assertEqual(hc.utc_offset(end, AMSTERDAM), 3600)
|
||||
|
||||
|
||||
class Messages(unittest.TestCase):
|
||||
def test_a_temperature_line(self):
|
||||
end = at('2026-09-16T16:40:00')
|
||||
start = end - 24 * 3600
|
||||
changes = [(start - 600, 20.05)] + [(start + i * 3600 + 1200, 19.0 + (i % 12) * 0.2) for i in range(24)]
|
||||
message = hc.line('sensor.living_room_temperature', 24, changes, start, end, AMSTERDAM, None, '°C')
|
||||
self.assertEqual((message['op'], message['kind'], message['hours'], message['off'], message['unit']), ('history', 'line', 24, 7200, '°C'))
|
||||
self.assertEqual(len(message['values']), 24)
|
||||
self.assertEqual(message['yt'][0][1][-1], '°')
|
||||
self.assertEqual(message['hi'][0], 21.2)
|
||||
self.assertEqual(message['lo'][0], 19.0)
|
||||
self.assertLessEqual(message['dom'][0], message['lo'][0])
|
||||
self.assertEqual(len(message['xt']), 4)
|
||||
self.assertLess(len(json.dumps(message, separators=(',', ':'))), 900)
|
||||
|
||||
def test_a_sensor_two_hours_old_fills_only_its_own_hours(self):
|
||||
# Added two hours ago: Home Assistant's history starts at its first state, nothing before it.
|
||||
end = at('2026-09-16T16:40:00')
|
||||
start = end - 24 * 3600
|
||||
changes = [(end - 7200, 480.0), (end - 3600, 510.0), (end - 1800, 530.0)]
|
||||
message = hc.line('sensor.new_plug_power', 24, changes, start, end, AMSTERDAM, None, 'W')
|
||||
self.assertEqual(message['values'][:22], [None] * 22)
|
||||
self.assertEqual(message['values'][22:], [480.0, 520.0])
|
||||
self.assertEqual((message['hi'], message['lo']), ([530.0, end - 1800], [480.0, end - 7200]))
|
||||
self.assertEqual([text for _, text in message['yt']], ['480', '500', '520'])
|
||||
# Statistics with one whole hour compiled so far, and the running hour not yet: that hour and no other.
|
||||
rows = [{'start': (end - 5400) // 3600 * 3600 * 1000, 'mean': 495.0, 'min': 480.0, 'max': 510.0}]
|
||||
means, extremes = hc.statistic_changes(rows, 3600)
|
||||
message = hc.line('sensor.new_plug_power', 24, means, start, end, AMSTERDAM, None, 'W', extremes)
|
||||
known = [i for i, value in enumerate(message['values']) if value is not None]
|
||||
self.assertEqual(known, [22, 23])
|
||||
# A day of a sensor added a minute ago with one state: one value, a flat axis around it.
|
||||
message = hc.line('sensor.new_plug_power', 24, [(end - 60, 12.0)], start, end, AMSTERDAM, None, 'W')
|
||||
self.assertEqual(sum(value is not None for value in message['values']), 1)
|
||||
self.assertLess(message['dom'][0], 12.0)
|
||||
self.assertGreater(message['dom'][1], 12.0)
|
||||
|
||||
def test_a_binary_sensor_two_hours_old_shows_no_data_before_it(self):
|
||||
end = at('2026-09-16T16:40:00')
|
||||
start = end - 24 * 3600
|
||||
changes = [(end - 7200, 'off'), (end - 3000, 'on'), (end - 2700, 'off')]
|
||||
message = hc.timeline('binary_sensor.new_door', 24, changes, start, end, AMSTERDAM, {'device_class': 'door'})
|
||||
self.assertEqual(message['states'], [['Closed', hc.OFF_GREY, 6900], ['Open', 'FFB300', 300]])
|
||||
first = message['seg'][0]
|
||||
self.assertEqual(first, [0, -1, 0, 79200, 79200], 'no data until the sensor existed')
|
||||
self.assertEqual((message['began'], message['active']), (1, 1))
|
||||
|
||||
def test_no_values_sends_an_empty_line(self):
|
||||
message = hc.line('sensor.t', 1, [], 0, 3600, timezone.utc)
|
||||
self.assertEqual(message['values'], [None] * 24)
|
||||
self.assertNotIn('yt', message)
|
||||
|
||||
def test_a_door_timeline_with_home_assistant_s_words(self):
|
||||
end = at('2026-09-16T16:40:00')
|
||||
start = end - 24 * 3600
|
||||
changes = [(start - 3600, 'off'), (start + 3600, 'on'), (start + 3900, 'off'), (start + 7200, 'unavailable'), (start + 7500, 'off'),
|
||||
(start + 36000, 'on'), (start + 36600, 'off')]
|
||||
message = hc.timeline('binary_sensor.front_door', 24, changes, start, end, AMSTERDAM, {'device_class': 'door'})
|
||||
self.assertEqual([label for label, _, _ in message['states']], ['Closed', 'Open', 'Unavailable'])
|
||||
self.assertEqual([color for _, color, _ in message['states']], [hc.OFF_GREY, 'FFB300', hc.NO_DATA])
|
||||
self.assertEqual({label: seconds for label, _, seconds in message['states']}, {'Closed': 85200, 'Open': 900, 'Unavailable': 300})
|
||||
self.assertEqual(message['began'], 2)
|
||||
# The heading follows the door while the card is open: both states in Home Assistant's words.
|
||||
self.assertEqual(dict(message['words']), {'off': 'Closed', 'on': 'Open', 'unavailable': 'Unavailable'})
|
||||
self.assertEqual(message['seg'][0][:2], [0, 0])
|
||||
openings = [run for run in message['seg'] if run[1] == 1]
|
||||
self.assertEqual(len(openings), 2, 'both openings show as their own run')
|
||||
# A finger reads the real opening, not the 15 minutes of its slot: 01:00 to 01:05 after the start, 5 minutes.
|
||||
self.assertEqual(openings[0][2:], [3600, 3900, 300])
|
||||
self.assertEqual(openings[1][2:], [36000, 36600, 600])
|
||||
|
||||
def test_people_and_their_zones(self):
|
||||
end = 7 * 24 * 3600
|
||||
changes = [(-10, 'not_home'), (3600, 'home'), (40000, 'work'), (70000, 'home')]
|
||||
message = hc.timeline('person.sam', 168, changes, 0, end, timezone.utc)
|
||||
labels = {label: color for label, color, _ in message['states']}
|
||||
self.assertEqual(labels, {'Home': '43A047', 'Work': '2196F3', 'Away': hc.OFF_GREY})
|
||||
self.assertEqual(message['began'], 2)
|
||||
self.assertEqual(dict(message['words']), {'not_home': 'Away', 'home': 'Home', 'work': 'Work'})
|
||||
|
||||
def test_every_zone_gets_its_own_colour(self):
|
||||
changes = [(-10, 'home'), (3600, 'not_home'), (4000, 'Office'), (9000, 'not_home'), (9500, 'Gym'), (12000, 'home')]
|
||||
message = hc.timeline('person.sam', 24, changes, 0, 24 * 3600, timezone.utc)
|
||||
colours = {label: color for label, color, _ in message['states']}
|
||||
self.assertEqual((colours['Home'], colours['Away']), ('43A047', hc.OFF_GREY))
|
||||
self.assertEqual((colours['Office'], colours['Gym']), (hc.PALETTE[0], hc.PALETTE[1]))
|
||||
|
||||
def test_many_states_join_other_and_the_message_stays_small(self):
|
||||
end = 24 * 3600
|
||||
states = ['idle', 'washing', 'rinsing', 'spinning', 'drying', 'paused', 'error', 'done']
|
||||
changes = [(i * 900, states[i % len(states)]) for i in range(96)]
|
||||
message = hc.timeline('sensor.washer', 24, changes, 0, end, timezone.utc, {})
|
||||
self.assertEqual(len(message['states']), 6)
|
||||
self.assertEqual(message['states'][-1][0], 'Other')
|
||||
# Paused, error and done follow each other in every cycle, and share "Other": one run of three slots.
|
||||
self.assertEqual(len(message['seg']), 72)
|
||||
# "Other" runs read the time of every state it stands for: paused, error and done follow each other.
|
||||
other = next(run for run in message['seg'] if run[1] == 5)
|
||||
self.assertEqual((other[3] - other[2], other[4]), (2700, 2700))
|
||||
self.assertLess(len(json.dumps(message, separators=(',', ':'))), 2500)
|
||||
|
||||
def test_a_busy_motion_sensor_for_a_week_stays_small_and_quick(self):
|
||||
# Motion every 30 seconds for a week: 20,160 changes from Home Assistant, still one small message for a CYD.
|
||||
import time
|
||||
import core
|
||||
end = 7 * 24 * 3600
|
||||
changes = [(i * 30, 'on' if i % 2 == 0 else 'off') for i in range(end // 30)]
|
||||
began = time.monotonic()
|
||||
message = hc.timeline('binary_sensor.hallway_motion', 168, changes, 0, end, AMSTERDAM, {'device_class': 'motion'})
|
||||
self.assertLess(time.monotonic() - began, 2.0)
|
||||
self.assertLessEqual(len(message['seg']), 96)
|
||||
self.assertEqual([(label, seconds) for label, _, seconds in message['states']], [('Motion', end // 2), ('No motion', end // 2)])
|
||||
self.assertEqual(message['began'], end // 60 - 1, 'the motion already on at the start did not begin in the range')
|
||||
self.assertLess(len(core.encode(message).encode()), 1024)
|
||||
|
||||
def test_the_largest_timeline_fits_one_screen_message(self):
|
||||
# A week in which the state changes in every slot, with the longest words and many states: still under the
|
||||
# 4096 bytes a screen takes.
|
||||
import core
|
||||
end = 7 * 24 * 3600
|
||||
states = [(f'state_{i:02d}_' + 'long_name_' * 3)[:32] for i in range(14)]
|
||||
span = end // 96
|
||||
changes = []
|
||||
for i in range(96):
|
||||
# Two states take turns slot by slot; a short third state in each slot fills the legend and the words.
|
||||
changes += [(i * span, states[i % 2]), (i * span + span * 8 // 10, states[2 + i % 12]), (i * span + span * 9 // 10, states[i % 2])]
|
||||
message = hc.timeline('sensor.status_of_the_washing_machine_in_the_basement', 168, changes, 0, end, AMSTERDAM, {})
|
||||
self.assertEqual((len(message['seg']), len(message['states']), len(message['words'])), (96, 6, 10))
|
||||
self.assertLessEqual(len(core.encode(message).encode()), 4096)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Requests(unittest.IsolatedAsyncioTestCase):
|
||||
"""A screen's esphome.screen_history event and the one message the manager answers it with."""
|
||||
|
||||
def manager(self, tmp, firmware='0.2.51'):
|
||||
import test_scaling
|
||||
from server import Manager
|
||||
ha = test_scaling.fake_ha(firmware=firmware)
|
||||
ha.states['binary_sensor.door'] = {'state': 'off', 'attributes': {'device_class': 'door'}}
|
||||
ha.states['light.a']['attributes']['brightness'] = 100
|
||||
ha.fetches, ha.fail = [], None
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
|
||||
async def statistic_rows(entity, hours):
|
||||
ha.fetches.append(('statistics', entity, hours))
|
||||
await asyncio.sleep(0.05)
|
||||
if ha.fail:
|
||||
raise ha.fail
|
||||
first = (now - (hours + 1) * 3600) // 3600 * 3600
|
||||
return [{'start': int((first + i * 3600) * 1000), 'mean': 20 + i % 3, 'min': 19, 'max': 23 + i % 2} for i in range(hours)]
|
||||
|
||||
async def state_changes(entity, hours):
|
||||
ha.fetches.append(('changes', entity, hours))
|
||||
if ha.fail:
|
||||
raise ha.fail
|
||||
return [(now - hours * 3600, 'off'), (now - 1800, 'on'), (now - 1500, 'off')] if entity == 'binary_sensor.door' else \
|
||||
[(now - hours * 3600, '21.0'), (now - 600, '22.5')]
|
||||
ha.statistic_rows, ha.state_changes = statistic_rows, state_changes
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.save('text.screen', {'title': 'Office 1', 'tiles': [{'entity': 'sensor.t', 'name': ''}, {'entity': 'binary_sensor.door', 'name': ''},
|
||||
{'entity': 'light.a', 'name': ''}]})
|
||||
return m
|
||||
|
||||
async def test_a_number_and_a_door_each_get_one_history_message(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp)
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': '24'})
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'binary_sensor.door', 'hours': '1'})
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': '1'})
|
||||
sent = [(inbox, message['op'], message['entity'], message['hours'], message['kind'], action) for inbox, message, action in m.ha.messages]
|
||||
action = 'esphome.office_1_screen_message'
|
||||
self.assertEqual(sent, [('text.screen', 'history', 'sensor.t', 24, 'line', action), ('text.screen', 'history', 'binary_sensor.door', 1, 'timeline', action),
|
||||
('text.screen', 'history', 'sensor.t', 1, 'line', action)])
|
||||
# A day comes from the hourly statistics, an hour from the exact changes.
|
||||
self.assertEqual(m.ha.fetches, [('statistics', 'sensor.t', 24), ('changes', 'binary_sensor.door', 1), ('changes', 'sensor.t', 1)])
|
||||
day, door, hour = (message for _, message, _ in m.ha.messages)
|
||||
self.assertEqual((day['unit'], len(day['values']), day['hi'][0], day['lo'][0]), ('°C', 24, 24, 19))
|
||||
self.assertEqual((door['began'], dict(door['words'])['on']), (1, 'Open'))
|
||||
self.assertEqual(hour['values'][-1], 22.5)
|
||||
for message in (day, door, hour):
|
||||
self.assertLessEqual(len(json.dumps(message, separators=(',', ':'))), 4096)
|
||||
|
||||
async def test_screens_asking_together_share_one_fetch_and_a_reopened_card_uses_it(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp)
|
||||
request = {'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 168}
|
||||
await asyncio.gather(m.answer_history(request), m.answer_history(dict(request)))
|
||||
await m.answer_history(dict(request))
|
||||
self.assertEqual(m.ha.fetches, [('statistics', 'sensor.t', 168)])
|
||||
self.assertEqual(len(m.ha.messages), 3)
|
||||
|
||||
async def test_a_failed_fetch_sends_and_keeps_nothing(self):
|
||||
from aiohttp import ClientError
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp)
|
||||
m.ha.fail = ClientError('recorder busy')
|
||||
with self.assertRaises(ClientError):
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 24})
|
||||
self.assertEqual((m.ha.messages, m.card_histories), ([], {}))
|
||||
m.ha.fail = None
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 24})
|
||||
self.assertEqual(len(m.ha.messages), 1, 'the card asks again and gets it')
|
||||
|
||||
async def test_what_is_not_answered(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp)
|
||||
for request in ({'inbox': 'text.screen', 'entity': 'sensor.co2', 'hours': 24}, # not on this screen
|
||||
{'inbox': 'text.other', 'entity': 'sensor.t', 'hours': 24},
|
||||
{'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 6},
|
||||
{'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 'soon'},
|
||||
{'inbox': 'text.screen', 'entity': 'light.a', 'hours': 24}, # a light's card has no history
|
||||
'text.screen'):
|
||||
await m.answer_history(request)
|
||||
m.ha.states['text.screen'] = {'state': 'unavailable'}
|
||||
await m.answer_history({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': 24})
|
||||
self.assertEqual((m.ha.messages, m.ha.fetches), ([], []))
|
||||
|
||||
async def test_the_loop_answers_events_and_survives_a_failure(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp)
|
||||
m.ha.history_requests = asyncio.Queue()
|
||||
m.ha.fail = OSError('down')
|
||||
loop = asyncio.ensure_future(m.card_history_loop())
|
||||
try:
|
||||
m.ha.history_requests.put_nowait({'inbox': 'text.screen', 'entity': 'sensor.t', 'hours': '24'})
|
||||
await asyncio.sleep(0.2)
|
||||
m.ha.fail = None
|
||||
m.ha.history_requests.put_nowait({'inbox': 'text.screen', 'entity': 'binary_sensor.door', 'hours': '24'})
|
||||
await asyncio.sleep(0.2)
|
||||
finally:
|
||||
loop.cancel()
|
||||
self.assertEqual([message['entity'] for _, message, _ in m.ha.messages], ['binary_sensor.door'])
|
||||
|
||||
|
||||
class Firmware(unittest.TestCase):
|
||||
def test_the_screen_asks_with_an_event_and_takes_only_the_answer_it_waits_for(self):
|
||||
request = RUNTIME[RUNTIME.index('inline void history_request('):]
|
||||
request = request[:request.index('\n}\n')]
|
||||
self.assertIn('request.service = esphome::StringRef("esphome.screen_history");', request)
|
||||
self.assertIn('request.is_event = true;', request, 'an event needs no permission to call actions')
|
||||
self.assertIn('const std::string keys[] = {"inbox", "entity", "hours"}', request)
|
||||
receive = RUNTIME[RUNTIME.index('if (op == "history") {'):]
|
||||
receive = receive[:receive.index('#ifdef SWIPE_PROFILE')]
|
||||
self.assertIn('if (next.entity != history_asked_entity || next.hours != history_asked_hours) {', receive)
|
||||
self.assertIn('if (!valid_entity(next.entity) || (next.hours != 1 && next.hours != 24 && next.hours != 168)) return false;', receive)
|
||||
|
||||
def test_the_card_opens_for_every_entity_with_history_and_keeps_its_range_keys_enabled(self):
|
||||
self.assertIn('return d=="sensor"||d=="binary_sensor"||d=="switch"||d=="input_boolean"||d=="person"||d=="number"||d=="input_number";', RUNTIME)
|
||||
self.assertIn('if(with_history){\n render_history_detail(t,large,width,height,pad);', RUNTIME)
|
||||
# Range keys ask the manager, not Home Assistant: they are left out of the keys a waiting command disables.
|
||||
ranges = RUNTIME[RUNTIME.index('inline void history_ranges('):]
|
||||
ranges = ranges[:ranges.index('\n}\n')]
|
||||
self.assertIn('if(detail_action_count&&detail_actions[detail_action_count-1]==segment)--detail_action_count;', ranges)
|
||||
self.assertIn('if(cmd>=160&&cmd<163){', RUNTIME)
|
||||
self.assertLess(RUNTIME.index('if(cmd>=160&&cmd<163){'), RUNTIME.index('if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd'))
|
||||
|
||||
def test_the_scrub_area_keeps_the_finger_and_the_boards_carry_the_small_font(self):
|
||||
touch = RUNTIME[RUNTIME.index('inline void history_touch('):]
|
||||
touch = touch[:touch.index('\n}\n')]
|
||||
for flag in ('lv_obj_add_flag(area,LV_OBJ_FLAG_PRESS_LOCK);', 'lv_obj_remove_flag(area,LV_OBJ_FLAG_GESTURE_BUBBLE);',
|
||||
'lv_obj_add_event_cb(area,history_scrub,LV_EVENT_ALL,nullptr);'):
|
||||
self.assertIn(flag, touch)
|
||||
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
|
||||
text = (ROOT / name).read_text()
|
||||
self.assertEqual(len(re.findall(r'runtime_tiles::small_font = id\(sublabel\)->get_lv_font\(\);', text)), 1, name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_history_view.cpp -o /tmp/test_history_view && /tmp/test_history_view
|
||||
#include "../components/smart_display/history_view.h"
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
using namespace history_view;
|
||||
|
||||
int main() {
|
||||
// 2026-09-16 14:40 UTC is 16:40 in Amsterdam (UTC+2), a Wednesday.
|
||||
const int64_t moment = 1789569600;
|
||||
assert(clock(moment, 7200, true, false) == "16:40");
|
||||
assert(clock(moment, 7200, false, false) == "4:40 PM");
|
||||
assert(clock(moment, 7200, true, true) == "Wed 16:40");
|
||||
assert(clock(moment - 16 * 3600 - 40 * 60, 7200, true, true) == "Wed 00:00");
|
||||
assert(clock(moment - 17 * 3600, 7200, false, true) == "Tue 11:40 PM");
|
||||
assert(clock(0, 0, true, true) == "Thu 00:00");
|
||||
assert(clock(-60, 0, false, true) == "Wed 11:59 PM");
|
||||
assert(clock(moment, -18000, true, false) == "09:40");
|
||||
assert(axis_clock(moment - 40 * 60, 7200, true) == "16:00");
|
||||
assert(axis_clock(moment - 40 * 60, 7200, false) == "4 PM");
|
||||
assert(axis_clock(moment - 25 * 60, 7200, false) == "4:15 PM");
|
||||
assert(axis_clock(moment - 16 * 3600 - 40 * 60, 7200, false) == "12 AM");
|
||||
|
||||
assert(duration(42) == "42 s");
|
||||
assert(duration(900) == "15 min");
|
||||
assert(duration(3600) == "1 h");
|
||||
assert(duration(12000) == "3 h 20 min");
|
||||
assert(duration(86400 * 2 + 3600 * 4) == "2 d 4 h");
|
||||
assert(duration(86400 * 7) == "7 d");
|
||||
|
||||
assert(number(21.43f, 1, "°C") == "21.4 °C");
|
||||
assert(number(40, 0, "%") == "40%");
|
||||
assert(number(1234.5f, 1, "W") == "1,234.5 W");
|
||||
assert(number(-2.5f, 1, "°") == "-2.5°");
|
||||
assert(number(-0.04f, 1, "°C") == "0.0 °C");
|
||||
assert(number(1234567, 0, "") == "1,234,567");
|
||||
assert(number(NAN, 1, "W") == "--");
|
||||
|
||||
assert(part_at(0.0f) == 0 && part_at(-0.2f) == 0);
|
||||
assert(part_at(0.5f) == 12 && part_at(0.49f) == 11);
|
||||
assert(part_at(0.999f) == 23 && part_at(1.0f) == 23 && part_at(1.5f) == 23);
|
||||
assert(std::fabs(part_middle(0) - 1.0f / 48) < 1e-6f && std::fabs(part_middle(23) - 47.0f / 48) < 1e-6f);
|
||||
|
||||
std::vector<Run> runs;
|
||||
for (auto [slot, state] : std::vector<std::pair<int, int>>{{0, 0}, {4, 1}, {5, 0}, {40, 1}, {41, 0}}) {
|
||||
Run run;
|
||||
run.slot = static_cast<uint16_t>(slot);
|
||||
run.state = static_cast<int8_t>(state);
|
||||
runs.push_back(run);
|
||||
}
|
||||
assert(run_at(runs, 96, 0.0f) == 0);
|
||||
assert(run_at(runs, 96, 4.5f / 96) == 1);
|
||||
assert(run_at(runs, 96, 0.3f) == 2);
|
||||
assert(run_at(runs, 96, 1.0f) == 4);
|
||||
assert(run_end(runs, 96, 1) == 5 && run_end(runs, 96, 4) == 96);
|
||||
assert(run_at({}, 96, 0.5f) == -1);
|
||||
|
||||
// The line never leaves the values it passes: a setpoint that steps from 55 to 45 and up to 60 stays within.
|
||||
struct P { int32_t x, y; };
|
||||
const float xs[] = {0, 10, 20, 30, 40, 50, 60}, ys[] = {55, 55, 55, 45, 45, 60, 60};
|
||||
P line[64];
|
||||
const unsigned count = monotone(xs, ys, 7, 4, line, 64);
|
||||
assert(count == 6 * 4 + 1);
|
||||
for (unsigned i = 0; i < count; ++i) assert(line[i].y >= 45 && line[i].y <= 60);
|
||||
for (unsigned i = 0; i + 1 < count; ++i) assert(line[i].x <= line[i + 1].x);
|
||||
assert(line[0].x == 0 && line[0].y == 55 && line[count - 1].x == 60 && line[count - 1].y == 60);
|
||||
// Flat stretches stay flat, and every given point is on the line.
|
||||
for (unsigned i = 0; i <= 4; ++i) assert(line[i].y == 55);
|
||||
for (unsigned i = 0; i < 7; ++i) assert(line[i * 4 < count ? i * 4 : count - 1].y == static_cast<int32_t>(ys[i]));
|
||||
// A small buffer is filled, not overrun, and ends on the last point.
|
||||
P small[5];
|
||||
assert(monotone(xs, ys, 7, 4, small, 5) == 5 && small[4].x == 60);
|
||||
// The highest moment takes the place of the nearest point in its part, so the line's peak is where its ring is.
|
||||
float px[8] = {0, 10, 20, 30}, py[8] = {5, 6, 7, 6};
|
||||
unsigned points = 4;
|
||||
const int high = through(px, py, points, 8, 22, 9, 5);
|
||||
assert(high == 2 && points == 4 && px[2] == 20 && py[2] == 9);
|
||||
// The lowest moment in the same part keeps the highest: it joins the line beside it.
|
||||
const int low = through(px, py, points, 8, 18, 1, 5, high);
|
||||
assert(low == 2 && points == 5 && px[2] == 18 && py[2] == 1 && px[3] == 20 && py[3] == 9);
|
||||
// Far from every point (a part without values) it is added.
|
||||
float gx[8] = {0, 10, 40}, gy[8] = {5, 5, 5};
|
||||
unsigned gaps = 3;
|
||||
assert(through(gx, gy, gaps, 8, 25, 8, 5) == 2 && gaps == 4 && gx[3] == 40);
|
||||
P peak[64];
|
||||
const unsigned drawn = monotone(px, py, points, 4, peak, 64);
|
||||
int top = 0, bottom = 100;
|
||||
for (unsigned i = 0; i < drawn; ++i) {
|
||||
top = std::max(top, peak[i].y);
|
||||
bottom = std::min(bottom, peak[i].y);
|
||||
}
|
||||
assert(top == 9 && bottom == 1);
|
||||
float full_x[2] = {0, 1}, full_y[2] = {0, 1};
|
||||
unsigned full = 2;
|
||||
assert(through(full_x, full_y, full, 2, 50, 3, 5) == -1 && full == 2);
|
||||
// One point, or none.
|
||||
assert(monotone(xs, ys, 1, 4, line, 64) == 1 && monotone(xs, ys, 0, 4, line, 64) == 0);
|
||||
return 0;
|
||||
}
|
||||