Change a screen's settings on the screen itself (0.2.52)

Holding the top bar for about a second and a half opens a settings page on the
screen, with a blue line filling along the top edge while you hold. A built-in
screen.settings tile and the esphome.<screen>_open_settings action open it too.

The page is one constexpr table of rows in settings_screen.h: Brightness,
Night, Screen (clock, back to page 1, swiping, rotation) and This screen (name,
address, firmware, Home Assistant, Restart). Toggles flip on a tap, numbers and
times step with -/+ (a held time walks whole hours), a choice cycles in a chip,
and a row that depends on a switch above it is greyed out. The LVGL objects exist
only while the page is open: +608 bytes of RAM and +10.7 KB of flash on the CYD.
A change is saved, applied and reported through the existing screen_setting
event, so ESP Screens shows the same value within a second.

New setting auto_home (default on, 2 minutes): an open card, the settings page
or a later page goes back to page 1 after that long without a touch, not only on
standby. It travels beside the frozen eleven-key settings block with its own
preference, so older firmware and older apps keep working.

docs/SETTINGS.md is the recipe for adding a setting; the Claude skill explains
the page. Firmware 0.2.44.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 10:05:53 +02:00
parent 93c789dc48
commit b83164d61a
28 changed files with 1645 additions and 27 deletions
+4
View File
@@ -49,6 +49,10 @@ physically tap; an agent cannot replace that with software coordinates.
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
in the gitignored local profiles.
- Screen settings live in one table: `components/smart_display/settings_screen.h` draws the
page on the screen, `SETTING_RULES` in the add-on validates and stores the same keys.
docs/SETTINGS.md is the end-to-end recipe for adding one; never widen the eleven-key
`settings` block older firmware insists on.
- Preserve fixed pages, hidden navigation at six tiles or fewer, and a
minimum default standby of 600 seconds. Don't reintroduce free scrolling
without physically testing for touch/navigation regressions.
+29
View File
@@ -94,6 +94,11 @@ The firmware and built-in CLI are tested with **ESPHome 2026.6.2**.
night hours, 24- or 12-hour clock, return to the home page, and optional swiping
between pages. Home Assistant automations can turn **Auto standby** off and on per
screen (firmware 0.2.41+), for example to keep a screen on while someone is home.
- **Settings on the screen itself** (firmware 0.2.44+): hold the top bar for about a
second and a half and the screen opens its own settings page — brightness, night,
the clock, swiping, rotation, and what this screen is (name, IP address, firmware,
whether Home Assistant is connected, and Restart). Changes show up in ESP Screens
within a second. See [Settings on the screen](#settings-on-the-screen).
- **Guition rotation:** 0°, 90°, 180°, or 270°, directly from the management page.
Native LVGL rotation turns the display and touch together. The CYD keeps its fixed
orientation and its own calibration.
@@ -313,6 +318,30 @@ If not everything fits next to the name, the name gets an ellipsis and the scree
leading items; the editor marks those with dashes. Until updated, older firmware shows
only the name and the time (if that's in the bar).
## Settings on the screen
Everything you would want to change while standing in front of the panel is on the screen
itself (firmware 0.2.44+). Tiles, the top bar and the pages stay in ESP Screens, where you
have a mouse.
**Opening it:** hold the top bar — the strip with the screen's name and the clock — until the
blue line along the top edge is full, about a second and a half. Letting go early cancels.
Rather have a button? Put the built-in **Settings** card on a page like any other tile. From
Home Assistant, `esphome.<screen>_open_settings` opens it too (`page` 0 menu, 1 Brightness,
2 Night, 3 Screen, 4 This screen, -1 closes it).
| Group | What is on it |
|---|---|
| Brightness | Brightness, Auto standby, Standby after, Standby brightness |
| Night | Night mode, Starts, Ends, Night brightness |
| Screen | 12/24-hour clock, back to page 1 by itself and after how long, also on standby, swiping between pages, rotation (boards that turn) |
| This screen | Name, IP address, firmware version, Home Assistant connected, Restart |
Tap a toggle to flip it, `-` and `+` to change a number or a time — hold them and a time walks
whole hours — and tap a chip like the clock to cycle it. Every change is saved on the screen,
takes effect at once, and appears in ESP Screens within a second, so both sides always show
the same value.
## Updates and keeping your settings
| Change | Action |
+4 -1
View File
@@ -22,7 +22,7 @@ inline bool valid_entity(const std::string &entity) {
!(entity[i] >= '0' && entity[i] <= '9') && entity[i] != '_') return false;
std::string domain = entity.substr(0, dot);
// screen.* are built-in cards without a Home Assistant entity behind them.
if (domain == "screen") return entity == "screen.clock";
if (domain == "screen") return entity == "screen.clock" || entity == "screen.settings";
for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person"})
if (domain == allowed) return true;
return false;
@@ -146,6 +146,9 @@ struct Tile {
void observe(uint32_t next) { revision=next; if (pending && revision!=pending_revision) confirmed=true; }
std::string domain() const { return entity.substr(0, entity.find('.')); }
bool builtin() const { return domain() == "screen"; }
// Two built-in cards, and only one of them is a clock that has to be redrawn every minute.
bool is_clock() const { return entity == "screen.clock"; }
bool is_settings() const { return entity == "screen.settings"; }
bool available() const { return builtin() || (received && state != "unknown" && state != "unavailable" && !state.empty()); }
bool active() const {
return state == "on" || state == "cleaning" || state == "active" || (domain() == "person" && state == "home") ||
+45 -10
View File
@@ -4,6 +4,7 @@
#include "tile_palette.h"
#include "tile_icon.h"
#include "screen_settings.h"
#include "settings_screen.h"
#include "esphome/core/preferences.h"
#include "cyd_ui.h"
#include "light_controls.h"
@@ -20,11 +21,16 @@
namespace runtime_tiles {
inline bool enabled = false;
inline bool swipe_pages = false;
inline uint32_t rotation = 0;
inline bool rotation_supported = false;
// Swiping, rotation and going back to page 1 live in settings_screen, next to the other settings the
// screen can change itself; these names stay as the way the rest of the firmware reaches them.
using settings_screen::swipe_pages;
using settings_screen::rotation;
using settings_screen::rotation_supported;
using settings_screen::auto_home;
using settings_screen::auto_home_seconds;
inline esphome::ESPPreferenceObject rotation_preference;
inline esphome::ESPPreferenceObject swipe_preference;
inline esphome::ESPPreferenceObject home_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
@@ -69,19 +75,36 @@ inline uint32_t keepalive_seconds = 120;
inline std::string layout_rev;
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.
struct HomeTimeout { uint32_t enabled = 1, seconds = 120; };
inline void load_settings() {
settings_preference = esphome::global_preferences->make_preference<screen_settings::Settings>(0x53435231);
swipe_preference = esphome::global_preferences->make_preference<uint32_t>(0x53575031);
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
HomeTimeout home;
if(home_preference.load(&home) && home.seconds>=30 && home.seconds<=3600){
auto_home=home.enabled?1:0;auto_home_seconds=(int32_t)home.seconds;
}
if(rotation_supported){
rotation_preference=esphome::global_preferences->make_preference<uint32_t>(0x524F5431);
uint32_t saved=0;
if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=saved;
if(rotation_preference.load(&saved) && saved<=270 && saved%90==0)rotation=(int32_t)saved;
}
screen_settings::Settings saved;
if (settings_preference.load(&saved) && saved.valid()) screen_settings::current = saved;
}
// Everything the screen remembers, written in one go. ESPHome batches the flash writes, so a row of
// taps on -/+ costs one write, and a value that did not change costs nothing.
inline void persist_settings() {
if (screen_settings::current.valid()) settings_preference.save(&screen_settings::current);
uint32_t swipe = swipe_pages ? 1 : 0;
swipe_preference.save(&swipe);
HomeTimeout home{(uint32_t) (auto_home ? 1 : 0), (uint32_t) std::clamp<int32_t>(auto_home_seconds, 30, 3600)};
home_preference.save(&home);
if (rotation_supported) { uint32_t turned = (uint32_t) rotation; rotation_preference.save(&turned); }
}
inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
// Require the complete known schema; validate before touching any runtime state.
if (obj.size() != 11) return false;
@@ -181,6 +204,9 @@ inline std::string receive(const std::string &payload) {
entities.push_back(entity.as<std::string>());
}
if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false;
if(!root["auto_home"].isNull() && !root["auto_home"].is<bool>())return false;
if(!root["auto_home_seconds"].isNull() && (!root["auto_home_seconds"].is<unsigned>() ||
root["auto_home_seconds"].as<unsigned>()<30 || root["auto_home_seconds"].as<unsigned>()>3600))return false;
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false;
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
@@ -202,6 +228,12 @@ inline std::string receive(const std::string &payload) {
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
swipe_pages=root["swipe_pages"].as<bool>();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved);
}
if((root["auto_home"].is<bool>() && auto_home!=root["auto_home"].as<bool>()) ||
(root["auto_home_seconds"].is<unsigned>() && auto_home_seconds!=(int32_t)root["auto_home_seconds"].as<unsigned>())){
if(root["auto_home"].is<bool>())auto_home=root["auto_home"].as<bool>();
if(root["auto_home_seconds"].is<unsigned>())auto_home_seconds=(int32_t)root["auto_home_seconds"].as<unsigned>();
HomeTimeout home{(uint32_t)(auto_home?1:0),(uint32_t)auto_home_seconds};home_preference.save(&home);
}
bool rotation_changed=false;
if(rotation_supported && root["rotation"].is<unsigned>() && rotation!=root["rotation"].as<unsigned>()){
rotation=root["rotation"].as<unsigned>();rotation_preference.save(&rotation);rotation_changed=true;
@@ -961,7 +993,7 @@ inline const char *icon_for(const Tile &tile) {
if (d == "sun") return tile.state == "above_horizon" ? "\U000F059B" : "\U000F059C";
if (d == "timer") return "\U000F051B";
if (d == "person") return "\U000F0004";
if (d == "screen") return "\U000F0150";
if (d == "screen") return tile.is_settings() ? "\U000F0493" : "\U000F0150";
return "\U000F0425";
}
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
@@ -1023,7 +1055,8 @@ inline void event(lv_event_t *event) {
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
if(d=="media_player" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("media_player.toggle",tile.entity);return;}
if(d=="climate" && tile.tap=="toggle" && code==LV_EVENT_SHORT_CLICKED){action("climate.toggle",tile.entity);return;}
if(tile.builtin())return;
// The clock card does nothing under a finger; the settings card opens the page.
if(tile.builtin()){if(tile.is_settings())settings_screen::open();return;}
// A short tap runs or pauses the timer; holding opens the card with a cancel button.
if(d=="timer" && !open){action(tile.state=="active"?"timer.pause":"timer.start",tile.entity);return;}
if(d=="sensor" || d=="binary_sensor" || d=="weather" || d=="number" || d=="input_number" || d=="select" || d=="input_select" || d=="media_player" || d=="vacuum" || d=="sun" || d=="person" || d=="timer") { tile.begin(esphome::millis(),true); active_index=w.index; show_detail(w.index); return; }
@@ -1653,7 +1686,9 @@ inline void render_slot(size_t slot) {
bool watch=t.display=="watch";
std::string unit=watch?t.unit:"";
std::string value = t.state;
if (!fresh() || !t.available()) value = "Unavailable";
// Nothing in Home Assistant stands behind the settings card, so it says the same with the link down.
if (t.is_settings()) value = "Tap to open";
else if (!fresh() || !t.available()) value = "Unavailable";
else if (d == "light" && t.state == "on" && std::isfinite(t.brightness)) value = std::to_string(static_cast<int>(std::lround(std::clamp(t.brightness, 0.0f, 255.0f) * 100 / 255))) + " %";
else if (d == "climate" && std::isfinite(t.target)) { char b[32]; snprintf(b, sizeof(b), "%.1f°", t.target); value = b; }
else if (d == "person") value = t.state=="home"?"Home":t.state=="not_home"?"Away":t.state;
@@ -1676,7 +1711,7 @@ inline void render_slot(size_t slot) {
bool large_tile=lv_obj_get_height(w.tile)>80;
lap(swipe_profile::TEXT);
// Cards that replace the name/status layout entirely.
bool clock=t.builtin(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available();
bool clock=t.is_clock(), forecast=d=="weather" && t.display=="forecast" && w.wide && t.extra().forecast.size()>0 && fresh() && t.available();
bool sunpath=d=="sun" && t.display=="sunpath" && w.wide && !t.extra().sunrise.empty() && !t.extra().sunset.empty() && fresh() && t.available();
bool graph=d=="sensor" && t.display=="graph" && t.has_history && !clock;
bool custom=clock||forecast||sunpath;
@@ -2306,9 +2341,9 @@ inline void tick() {
for(size_t slot=0;slot<SLOTS_PER_PAGE;++slot){
auto &w=widgets[slot];if(!w.tile || w.index>=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
const auto &t=model.tiles[w.index];
if((t.builtin() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
if((t.is_clock() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
// The second hand moves on its own: only its line is redrawn, and it hides during standby.
else if(w.parts[18] && w.points && t.builtin() && t.display=="analog")second_hand(w,now);
else if(w.parts[18] && w.points && t.is_clock() && t.display=="analog")second_hand(w,now);
}
}
if(redraw && refresh)refresh();
+733
View File
@@ -0,0 +1,733 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstdint>
#include <iterator>
#include <cstdio>
#include <string>
#include "screen_settings.h"
// The settings page on the screen itself (firmware 0.2.44+). It changes the same values ESP Screen
// Manager writes, for the moments you stand in front of the panel instead of behind a browser.
//
// Everything the page shows is one table: `pages[]` of `Row`s. A row knows how to read and write its
// value and what kind of control it needs, so a new setting is one line in that table -- no drawing
// code, no layout. docs/SETTINGS.md walks through adding one end to end.
//
// The model (rows, stepping, the texts) is free of LVGL so tests/test_settings_screen.cpp covers it
// on a PC; the drawing below it is behind SETTINGS_SCREEN_TEST.
namespace settings_screen {
// Values that are not part of the persisted `screen_settings::Settings` block, because that format is
// frozen at version 1: each of these keeps its own preference, so a new option never rewrites the
// old ones. The manager sends them as their own keys in the layout message.
inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120;
inline bool rotation_supported = false;
// ------------------------------------------------------------------ the table
enum class Kind : uint8_t { page, toggle, number, duration, moment, choice, info, action };
using Read = int32_t (*)();
using Write = void (*)(int32_t);
using Text = std::string (*)();
using Shown = bool (*)();
using Run = void (*)();
struct Row {
Kind kind = Kind::info;
const char *label = "";
const char *icon = ""; // page rows and actions; a Material Design Icons glyph
Read read = nullptr; // toggle, number, duration, moment, choice
Write write = nullptr;
int32_t low = 0, high = 0, step = 0; // number: fixed step; duration: seconds, step from the ladder
const char *unit = ""; // straight after a number ("%")
const char *const *options = nullptr; // choice
uint8_t option_count = 0;
Text text = nullptr; // info
Run run = nullptr; // action
Shown shown = nullptr; // absent rows: rotation on a board that cannot turn
Shown enabled = nullptr; // greyed out while the switch it depends on is off
uint8_t opens = 0; // page rows: the page they open
};
constexpr Row page_row(const char *label, const char *icon, uint8_t opens) {
Row r{}; r.kind = Kind::page; r.label = label; r.icon = icon; r.opens = opens; return r;
}
constexpr Row toggle(const char *label, Read read, Write write, Shown shown = nullptr) {
Row r{}; r.kind = Kind::toggle; r.label = label; r.read = read; r.write = write; r.shown = shown; return r;
}
constexpr Row number(const char *label, Read read, Write write, int32_t low, int32_t high, int32_t step,
const char *unit = "", Shown enabled = nullptr) {
Row r{}; r.kind = Kind::number; r.label = label; r.read = read; r.write = write;
r.low = low; r.high = high; r.step = step; r.unit = unit; r.enabled = enabled; return r;
}
constexpr Row duration(const char *label, Read read, Write write, int32_t low, int32_t high,
Shown enabled = nullptr) {
Row r{}; r.kind = Kind::duration; r.label = label; r.read = read; r.write = write;
r.low = low; r.high = high; r.enabled = enabled; return r;
}
constexpr Row moment(const char *label, Read read, Write write, Shown enabled = nullptr) {
Row r{}; r.kind = Kind::moment; r.label = label; r.read = read; r.write = write;
r.low = 0; r.high = 1439; r.step = 15; r.enabled = enabled; return r;
}
constexpr Row choice(const char *label, Read read, Write write, const char *const *options, uint8_t count,
Shown shown = nullptr) {
Row r{}; r.kind = Kind::choice; r.label = label; r.read = read; r.write = write;
r.options = options; r.option_count = count; r.shown = shown; return r;
}
constexpr Row info(const char *label, Text text) {
Row r{}; r.kind = Kind::info; r.label = label; r.text = text; return r;
}
constexpr Row action(const char *label, const char *icon, Run run) {
Row r{}; r.kind = Kind::action; r.label = label; r.icon = icon; r.run = run; return r;
}
// ------------------------------------------------------------------ values
// One place where a change is kept, put into effect and sent to the manager, so the add-on's page
// shows what the screen shows. The same path the Home Assistant number entities already take.
inline void (*store)() = nullptr; // save the preferences
inline void (*apply)() = nullptr; // backlight, rotation, night
inline void (*report)(const char *, int32_t) = nullptr; // esphome.screen_setting event
inline void changed(const char *key, int32_t value) {
if (store) store();
if (apply) apply();
if (report) report(key, value);
}
// How coarse a duration steps: seconds near the bottom, quarters of an hour at the top. Stepping down
// reads the ladder one second lower, so up and down always land on the same values again.
inline int32_t ladder_step(int32_t seconds) {
if (seconds < 300) return 30;
if (seconds < 900) return 60;
if (seconds < 3600) return 300;
if (seconds < 7200) return 900;
return 1800;
}
// The value one press of - or + gives. Moments wrap around midnight; everything else stops at its end.
// `held` is a finger that has been on the key for a while: a time then walks whole hours instead of
// quarters, so setting night from 22:00 to 07:00 is one hold and not thirty-six taps.
inline int32_t stepped(const Row &row, int32_t value, int direction, bool held = false) {
if (row.kind == Kind::moment) {
int32_t step = held ? 60 : row.step;
// From a hold, land on the whole hour first; after that every step is an hour.
int32_t next = held && value % 60 ? (value / 60) * 60 + (direction > 0 ? 60 : 0) : value + direction * step;
int32_t span = row.high + 1;
next %= span;
return next < 0 ? next + span : next;
}
int32_t step = row.kind == Kind::duration ? ladder_step(direction < 0 ? value - 1 : value) : row.step;
return std::clamp<int32_t>(value + direction * step, row.low, row.high);
}
inline bool at_end(const Row &row, int32_t value, int direction) {
if (row.kind == Kind::moment) return false;
return stepped(row, value, direction) == value;
}
inline std::string duration_text(int32_t seconds) {
char buffer[24];
if (seconds < 60) { snprintf(buffer, sizeof(buffer), "%d sec", (int) seconds); return buffer; }
if (seconds < 3600) { snprintf(buffer, sizeof(buffer), "%d min", (int) (seconds / 60)); return buffer; }
int hours = seconds / 3600, minutes = (seconds % 3600) / 60;
if (minutes) snprintf(buffer, sizeof(buffer), "%d h %02d", hours, minutes);
else snprintf(buffer, sizeof(buffer), "%d h", hours);
return buffer;
}
// Minutes since midnight as the clock on this screen shows them.
inline std::string moment_text(int32_t minutes, bool clock_24h) {
char buffer[16];
int hour = minutes / 60, minute = minutes % 60;
if (clock_24h) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute);
else snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour % 12 ? hour % 12 : 12, minute, hour < 12 ? "AM" : "PM");
return buffer;
}
// What the right-hand side of a row says. A toggle draws a switch instead, its text is for the tests.
inline std::string value_text(const Row &row) {
switch (row.kind) {
case Kind::toggle: return row.read && row.read() ? "On" : "Off";
case Kind::number: {
char buffer[24];
snprintf(buffer, sizeof(buffer), "%d%s", row.read ? (int) row.read() : 0, row.unit);
return buffer;
}
case Kind::duration: return duration_text(row.read ? row.read() : 0);
case Kind::moment: return moment_text(row.read ? row.read() : 0, screen_settings::current.clock_24h != 0);
case Kind::choice: {
int32_t index = row.read ? row.read() : 0;
return index >= 0 && index < row.option_count ? row.options[index] : "";
}
case Kind::info: return row.text ? row.text() : "";
default: return "";
}
}
inline bool visible_row(const Row &row) { return !row.shown || row.shown(); }
inline bool live_row(const Row &row) { return !row.enabled || row.enabled(); }
struct Page {
const char *title;
const Row *rows;
uint8_t count;
};
// How many rows fit, and whether the page therefore needs its pager. Reserving the pager only when it
// is really needed keeps a group that just fits on one page.
inline uint8_t fitting_rows(int span, int row_height, int gap, int pager, uint8_t count, bool &paged) {
int loose = std::max(1, (span + gap) / (row_height + gap));
paged = count > loose;
if (!paged) return count;
return (uint8_t) std::max(1, (span - pager + gap) / (row_height + gap));
}
// ------------------------------------------------------------------ the pages
inline constexpr const char *clock_options[] = {"12 hour", "24 hour"};
inline constexpr const char *rotation_options[] = {"0°", "90°", "180°", "270°"};
// Brightness, and when the screen dims by itself.
// Every reader says `-> int32_t` out loud: on the ESP32 that is `long`, and a lambda that returns a
// plain `int` (a ternary, say) then does not convert to Read at all. The Mac's host build accepts it,
// the board does not.
inline constexpr Row light_rows[] = {
number("Brightness", []() -> int32_t { return screen_settings::current.brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.brightness = std::clamp<int32_t>(value, 5, 100);
s.standby_brightness = std::min(s.standby_brightness, s.brightness);
s.night_brightness = std::min(s.night_brightness, s.brightness);
changed("brightness", s.brightness);
}, 5, 100, 5, "%"),
toggle("Auto standby", []() -> int32_t { return screen_settings::current.standby_enabled; },
[](int32_t value) {
screen_settings::current.standby_enabled = value ? 1 : 0;
changed("standby_enabled", screen_settings::current.standby_enabled);
}),
duration("Standby after", []() -> int32_t { return screen_settings::current.standby_seconds; },
[](int32_t value) {
screen_settings::current.standby_seconds = value;
changed("standby_seconds", value);
}, 60, 86400, [] { return screen_settings::current.standby_enabled != 0; }),
number("Standby brightness", []() -> int32_t { return screen_settings::current.standby_brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.standby_brightness = std::clamp<int32_t>(value, 0, s.brightness);
changed("standby_brightness", s.standby_brightness);
}, 0, 100, 5, "%", [] { return screen_settings::current.standby_enabled != 0; }),
};
// Darker between two times, so a panel in a hallway does not light up the bedroom.
inline constexpr Row night_rows[] = {
toggle("Night mode", []() -> int32_t { return screen_settings::current.night_enabled; },
[](int32_t value) {
screen_settings::current.night_enabled = value ? 1 : 0;
changed("night_enabled", screen_settings::current.night_enabled);
}),
moment("Starts", []() -> int32_t { return screen_settings::current.night_start; },
[](int32_t value) { screen_settings::current.night_start = value; changed("night_start", value); },
[] { return screen_settings::current.night_enabled != 0; }),
moment("Ends", []() -> int32_t { return screen_settings::current.night_end; },
[](int32_t value) { screen_settings::current.night_end = value; changed("night_end", value); },
[] { return screen_settings::current.night_enabled != 0; }),
number("Night brightness", []() -> int32_t { return screen_settings::current.night_brightness; },
[](int32_t value) {
auto &s = screen_settings::current;
s.night_brightness = std::clamp<int32_t>(value, 0, s.brightness);
changed("night_brightness", s.night_brightness);
}, 0, 100, 5, "%", [] { return screen_settings::current.night_enabled != 0; }),
};
// How the screen behaves under your finger: the clock, going back to the first page, swiping, turning.
inline constexpr Row screen_rows[] = {
choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; },
[](int32_t value) {
screen_settings::current.clock_24h = value ? 1 : 0;
changed("clock_24h", screen_settings::current.clock_24h);
}, clock_options, 2),
toggle("Back to page 1", []() -> int32_t { return auto_home; },
[](int32_t value) { auto_home = value ? 1 : 0; changed("auto_home", auto_home); }),
duration("After", []() -> int32_t { return auto_home_seconds; },
[](int32_t value) { auto_home_seconds = value; changed("auto_home_seconds", value); }, 30, 3600,
[] { return auto_home != 0; }),
toggle("Also on standby", []() -> int32_t { return screen_settings::current.home_on_standby; },
[](int32_t value) {
screen_settings::current.home_on_standby = value ? 1 : 0;
changed("home_on_standby", screen_settings::current.home_on_standby);
}),
toggle("Swipe between pages", []() -> int32_t { return swipe_pages; },
[](int32_t value) { swipe_pages = value ? 1 : 0; changed("swipe_pages", swipe_pages); }),
choice("Rotation", []() -> int32_t { return rotation / 90; },
[](int32_t value) { rotation = std::clamp<int32_t>(value, 0, 3) * 90; changed("rotation", rotation); },
rotation_options, 4, [] { return rotation_supported; }),
};
// Read-only facts plus the one action: what you want when something is stuck.
inline std::string (*name_text)() = nullptr;
inline std::string (*address_text)() = nullptr;
inline std::string (*firmware_text)() = nullptr;
inline std::string (*link_text)() = nullptr;
inline void (*restart_device)() = nullptr;
inline constexpr Row about_rows[] = {
info("Screen", [] { return name_text ? name_text() : std::string(); }),
info("Address", [] { return address_text ? address_text() : std::string(); }),
info("Firmware", [] { return firmware_text ? firmware_text() : std::string(); }),
info("Home Assistant", [] { return link_text ? link_text() : std::string(); }),
action("Restart", "\U000F0709", [] { if (restart_device) restart_device(); }),
};
inline constexpr Row menu_rows[] = {
page_row("Brightness", "\U000F0599", 1),
page_row("Night", "\U000F0594", 2),
page_row("Screen", "\U000F0379", 3),
page_row("This screen", "\U000F02FD", 4),
};
inline constexpr Page pages[] = {
{"Settings", menu_rows, (uint8_t) std::size(menu_rows)},
{"Brightness", light_rows, (uint8_t) std::size(light_rows)},
{"Night", night_rows, (uint8_t) std::size(night_rows)},
{"Screen", screen_rows, (uint8_t) std::size(screen_rows)},
{"This screen", about_rows, (uint8_t) std::size(about_rows)},
};
constexpr uint8_t PAGE_COUNT = (uint8_t) std::size(pages);
} // namespace settings_screen
#ifndef SETTINGS_SCREEN_TEST
#include "lvgl.h"
#include <functional>
namespace settings_screen {
// Bound by the board profile on boot: the fonts of the board and what the page may do to it.
inline const lv_font_t *title_font = nullptr, *row_font = nullptr, *icon_font = nullptr;
inline std::function<bool()> may_open; // awake, no calibration, runtime tiles alive
inline std::function<int()> drift; // how far the finger travelled during this touch
inline std::function<void()> before_open; // close whatever card is open first
// Holding the top bar opens the page; a finger that travels further than this is swiping, not holding.
inline uint32_t hold_ms = 1500;
inline int hold_limit = 20;
// How far a tap on a row may drift, the same figure the board's own touch guard uses for tiles
// (a centimetre on the resistive CYD, no limit on the capacitive Guition). 0 is no limit.
inline int tap_limit = 0;
// The page while it is on screen; nothing of it exists when it is closed, which is how a 320x240
// board with 60 KB of free heap can afford a settings page at all.
inline lv_obj_t *root = nullptr, *hold_area = nullptr, *hold_bar = nullptr;
inline uint8_t current_page = 0, first_row = 0;
inline int confirm_row = -1; // the action row that asked "tap again"
inline lv_timer_t *confirm_timer = nullptr;
struct Drawn {
lv_obj_t *card = nullptr, *value = nullptr, *knob = nullptr, *minus = nullptr, *plus = nullptr, *label = nullptr;
uint8_t row = 0;
};
inline std::array<Drawn, 8> drawn{};
inline uint8_t drawn_count = 0;
inline bool visible() { return root != nullptr; }
struct Metrics {
bool large;
int width, height, pad, radius, inset, bar, bar_y, rows_y, row_h, gap, pager, bottom, pill_w, pill_h, switch_w, switch_h;
};
inline Metrics metrics() {
int width = lv_display_get_horizontal_resolution(lv_display_get_default());
int height = lv_display_get_vertical_resolution(lv_display_get_default());
bool large = width >= 480;
return Metrics{large, width, height,
large ? 20 : 10, large ? 18 : 10, large ? 18 : 10,
large ? 56 : 36, large ? 16 : 7,
large ? 92 : 50, large ? 52 : 32, large ? 8 : 5,
large ? 40 : 26, large ? 16 : 10,
large ? 44 : 30, large ? 40 : 26,
large ? 62 : 40, large ? 34 : 22};
}
inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
auto *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_remove_flag(obj, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
lv_obj_set_pos(obj, x, y);
lv_obj_set_size(obj, w, h);
return obj;
}
inline lv_obj_t *text(lv_obj_t *parent, const std::string &value, const lv_font_t *font, uint32_t color,
lv_text_align_t align = LV_TEXT_ALIGN_LEFT) {
auto *label = lv_label_create(parent);
lv_label_set_text(label, value.c_str());
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
lv_obj_remove_flag(label, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_text_font(label, font, 0);
lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
lv_obj_set_style_text_align(label, align, 0);
lv_obj_set_height(label, lv_font_get_line_height(font));
return label;
}
inline void draw();
// ---- the controls on a row ----
// A round key like the -/+ on the climate card: the finger sees the press in the same frame.
inline lv_obj_t *key(lv_obj_t *parent, const char *glyph, int x, int y, int w, int h, lv_event_cb_t handler,
void *data) {
auto *button = plain(parent, x, y, w, h);
lv_obj_add_flag(button, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(button, lv_color_hex(0xE9EFF4), 0);
lv_obj_set_style_bg_color(button, lv_color_hex(0xC3D5E3), LV_STATE_PRESSED);
lv_obj_set_style_radius(button, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_opa(button, LV_OPA_40, LV_STATE_DISABLED);
auto *label = text(button, glyph, icon_font ? icon_font : row_font, 0x1B1B1B);
lv_obj_set_width(label, LV_SIZE_CONTENT);
lv_obj_center(label);
lv_obj_add_event_cb(button, handler, LV_EVENT_SHORT_CLICKED, data);
lv_obj_add_event_cb(button, handler, LV_EVENT_LONG_PRESSED_REPEAT, data);
return button;
}
// Not an lv_switch: the row itself is the target, so the pill only has to show the state and can
// never swallow half the taps that land next to it.
inline lv_obj_t *pill(lv_obj_t *parent, int x, int y, int w, int h, bool on) {
auto *track = plain(parent, x, y, w, h);
lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(track, lv_color_hex(on ? 0x009FE3 : 0xC4C4C4), 0);
lv_obj_set_style_radius(track, LV_RADIUS_CIRCLE, 0);
int inset = std::max(2, h / 10), size = h - 2 * inset;
auto *knob = plain(track, on ? w - inset - size : inset, inset, size, size);
lv_obj_set_style_bg_opa(knob, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(knob, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_radius(knob, LV_RADIUS_CIRCLE, 0);
return knob;
}
inline void move_knob(Drawn &d, const Row &row) {
if (!d.knob) return;
auto *track = lv_obj_get_parent(d.knob);
bool on = row.read && row.read();
int w = lv_obj_get_width(track), size = lv_obj_get_width(d.knob), inset = (lv_obj_get_height(track) - size) / 2;
lv_obj_set_style_bg_color(track, lv_color_hex(on ? 0x009FE3 : 0xC4C4C4), 0);
lv_obj_set_x(d.knob, on ? w - inset - size : inset);
}
// After any change: every row on this page tells its own value again. Cheap (eight labels at most) and
// it keeps rows that lean on each other honest -- lowering Brightness also lowers the two dim levels.
inline void refresh() {
const Page &page = pages[current_page];
for (uint8_t i = 0; i < drawn_count; ++i) {
Drawn &d = drawn[i];
const Row &row = page.rows[d.row];
if (d.value) lv_label_set_text(d.value, value_text(row).c_str());
if (d.knob) move_knob(d, row);
// A row whose switch is off (night times, the standby levels) is there but does nothing, and says so.
bool live = live_row(row);
if (d.card) lv_obj_set_style_opa(d.card, live ? LV_OPA_COVER : LV_OPA_50, 0);
if (d.minus && row.read) {
bool off = !live || at_end(row, row.read(), -1);
if (off) lv_obj_add_state(d.minus, LV_STATE_DISABLED); else lv_obj_remove_state(d.minus, LV_STATE_DISABLED);
}
if (d.plus && row.read) {
bool off = !live || at_end(row, row.read(), 1);
if (off) lv_obj_add_state(d.plus, LV_STATE_DISABLED); else lv_obj_remove_state(d.plus, LV_STATE_DISABLED);
}
}
}
inline void forget_confirm() {
if (confirm_timer) { lv_timer_delete(confirm_timer); confirm_timer = nullptr; }
confirm_row = -1;
}
// ---- events ----
inline bool steady(int limit) { return !drift || limit <= 0 || drift() <= limit; }
// Holding - or + repeats, but at the pace of the climate card (five a second) instead of LVGL's ten.
// After a second of holding the steps grow; a single tap is always the small step.
inline uint32_t last_repeat = 0;
inline uint8_t repeats = 0;
constexpr uint8_t REPEATS_BEFORE_FAST = 5;
inline void step_event(lv_event_t *event) {
if (!steady(tap_limit)) return;
bool held = false;
if (lv_event_get_code(event) == LV_EVENT_LONG_PRESSED_REPEAT) {
uint32_t now = lv_tick_get();
if (now - last_repeat < 200) return;
last_repeat = now;
if (repeats < 255) ++repeats;
held = repeats > REPEATS_BEFORE_FAST;
} else {
repeats = 0;
}
int data = (int) (intptr_t) lv_event_get_user_data(event);
const Page &page = pages[current_page];
uint8_t index = (uint8_t) (data >> 1);
if (index >= page.count) return;
const Row &row = page.rows[index];
if (!row.read || !row.write || !live_row(row)) return;
int32_t value = row.read();
int32_t next = stepped(row, value, data & 1 ? 1 : -1, held);
if (next == value) return;
row.write(next);
refresh();
}
inline void row_event(lv_event_t *event) {
if (!steady(tap_limit)) return;
int index = (int) (intptr_t) lv_event_get_user_data(event);
const Page &page = pages[current_page];
if (index < 0 || index >= page.count) return;
const Row &row = page.rows[index];
if (!live_row(row)) return;
if (row.kind == Kind::page) { current_page = row.opens; first_row = 0; forget_confirm(); draw(); return; }
if (row.kind == Kind::toggle && row.read && row.write) { row.write(row.read() ? 0 : 1); refresh(); return; }
if (row.kind == Kind::choice && row.read && row.write && row.option_count) {
row.write((row.read() + 1) % row.option_count);
refresh();
return;
}
if (row.kind == Kind::action) {
// Nothing on this page is worth a dialog, except the one row that takes the screen away for ten
// seconds: it asks once, in place, and forgets the question after five.
if (confirm_row == index) { forget_confirm(); if (row.run) row.run(); return; }
forget_confirm();
confirm_row = index;
confirm_timer = lv_timer_create([](lv_timer_t *) { forget_confirm(); draw(); }, 5000, nullptr);
lv_timer_set_repeat_count(confirm_timer, 1);
draw();
}
}
inline void close();
inline void back_event(lv_event_t *) {
forget_confirm();
if (current_page == 0) { close(); return; }
current_page = 0;
first_row = 0;
draw();
}
inline void pager_event(lv_event_t *event) {
int direction = (int) (intptr_t) lv_event_get_user_data(event);
int next = (int) first_row + direction;
if (next < 0) return;
first_row = (uint8_t) next;
forget_confirm();
draw();
}
// ---- drawing ----
inline void draw() {
if (!root) return;
Metrics m = metrics();
lv_obj_clean(root);
drawn_count = 0;
const Page &page = pages[current_page];
// Rows a board does not have (rotation on the CYD) leave the table out of sight entirely.
std::array<uint8_t, 12> shown{};
uint8_t count = 0;
for (uint8_t i = 0; i < page.count && count < shown.size(); ++i)
if (visible_row(page.rows[i])) shown[count++] = i;
// Top bar: the same round back arrow and centred name as the cards of a tile.
auto *back = plain(root, m.pad, m.bar_y, m.bar, m.bar);
lv_obj_add_flag(back, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(back, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(back, lv_color_hex(0xEEEEEE), 0);
lv_obj_set_style_bg_color(back, lv_color_hex(0xDADADA), LV_STATE_PRESSED);
lv_obj_set_style_radius(back, LV_RADIUS_CIRCLE, 0);
auto *arrow = text(back, "\U000F004D", icon_font ? icon_font : row_font, 0x1B1B1B);
lv_obj_set_width(arrow, LV_SIZE_CONTENT);
lv_obj_center(arrow);
lv_obj_add_event_cb(back, back_event, LV_EVENT_SHORT_CLICKED, nullptr);
const lv_font_t *heading = title_font ? title_font : row_font;
auto *title = text(root, page.title, heading, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(title, m.width - 2 * (m.pad + m.bar + 8));
lv_obj_set_pos(title, m.pad + m.bar + 8, m.bar_y + (m.bar - lv_font_get_line_height(heading)) / 2);
int span = m.height - m.rows_y - m.bottom;
bool paged = false;
uint8_t per_page = std::min<uint8_t>(fitting_rows(span, m.row_h, m.gap, m.pager, count, paged), drawn.size());
// Always start a page on a page boundary, and never scroll a group that fits.
first_row = paged && first_row < count ? (uint8_t) ((first_row / per_page) * per_page) : 0;
int label_h = lv_font_get_line_height(row_font);
for (uint8_t slot = 0; slot < per_page && first_row + slot < count; ++slot) {
uint8_t index = shown[first_row + slot];
const Row &row = page.rows[index];
Drawn &d = drawn[slot];
d = Drawn{};
d.row = index;
bool tappable = row.kind == Kind::page || row.kind == Kind::toggle || row.kind == Kind::choice ||
row.kind == Kind::action;
bool asking = row.kind == Kind::action && confirm_row == index;
d.card = plain(root, m.pad, m.rows_y + slot * (m.row_h + m.gap), m.width - 2 * m.pad, m.row_h);
lv_obj_set_style_bg_opa(d.card, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xFFFFFF), 0);
lv_obj_set_style_radius(d.card, m.radius, 0);
lv_obj_set_style_border_width(d.card, 1, 0);
lv_obj_set_style_border_color(d.card, lv_color_hex(asking ? 0x009FE3 : 0xDDDDDD), 0);
if (tappable) {
lv_obj_add_flag(d.card, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(d.card, lv_color_hex(asking ? 0x0083BC : 0xF1F1F1), LV_STATE_PRESSED);
lv_obj_add_event_cb(d.card, row_event, LV_EVENT_SHORT_CLICKED, (void *) (intptr_t) index);
}
int left = m.inset;
if (row.icon && *row.icon) {
auto *glyph = text(d.card, row.icon, icon_font ? icon_font : row_font, asking ? 0xFFFFFF : 0x4A4A4A);
int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font);
lv_obj_set_width(glyph, LV_SIZE_CONTENT);
lv_obj_set_pos(glyph, left, (m.row_h - icon_h) / 2);
left += icon_h + (m.large ? 12 : 8);
}
d.label = text(d.card, asking ? "Tap again to restart" : row.label, row_font, asking ? 0xFFFFFF : 0x1B1B1B);
lv_obj_set_pos(d.label, left, (m.row_h - label_h) / 2);
int right = m.width - 2 * m.pad - m.inset; // free space from the right edge of the card
if (row.kind == Kind::page) {
auto *chevron = text(d.card, "\U000F0142", icon_font ? icon_font : row_font, 0xB0B0B0);
int icon_h = lv_font_get_line_height(icon_font ? icon_font : row_font);
lv_obj_set_width(chevron, LV_SIZE_CONTENT);
lv_obj_set_pos(chevron, right - icon_h, (m.row_h - icon_h) / 2);
lv_obj_set_width(d.label, right - icon_h - left - 6);
} else if (row.kind == Kind::toggle) {
d.knob = pill(d.card, right - m.switch_w, (m.row_h - m.switch_h) / 2, m.switch_w, m.switch_h, row.read && row.read());
lv_obj_set_width(d.label, right - m.switch_w - left - 6);
} else if (row.kind == Kind::number || row.kind == Kind::duration || row.kind == Kind::moment) {
int y = (m.row_h - m.pill_h) / 2, value_w = m.large ? 116 : 74;
d.plus = key(d.card, "\U000F0415", right - m.pill_w, y, m.pill_w, m.pill_h, step_event,
(void *) (intptr_t) (index * 2 + 1));
d.minus = key(d.card, "\U000F0374", right - m.pill_w - value_w - m.pill_w, y, m.pill_w, m.pill_h, step_event,
(void *) (intptr_t) (index * 2));
d.value = text(d.card, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(d.value, value_w);
lv_obj_set_pos(d.value, right - m.pill_w - value_w, (m.row_h - label_h) / 2);
lv_obj_set_width(d.label, right - 2 * m.pill_w - value_w - left - 6);
} else if (row.kind == Kind::choice) {
int value_w = m.large ? 150 : 96, chip_h = m.pill_h;
auto *chip = plain(d.card, right - value_w, (m.row_h - chip_h) / 2, value_w, chip_h);
lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(chip, lv_color_hex(0xE9EFF4), 0);
lv_obj_set_style_radius(chip, LV_RADIUS_CIRCLE, 0);
d.value = text(chip, value_text(row), row_font, 0x1B1B1B, LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(d.value, value_w - 8);
lv_obj_set_pos(d.value, 4, (chip_h - label_h) / 2);
lv_obj_set_width(d.label, right - value_w - left - 6);
} else if (row.kind == Kind::info) {
d.value = text(d.card, value_text(row), row_font, 0x616161, LV_TEXT_ALIGN_RIGHT);
int value_w = (m.width - 2 * m.pad) / 2;
lv_obj_set_width(d.value, value_w);
lv_obj_set_pos(d.value, right - value_w, (m.row_h - label_h) / 2);
lv_obj_set_width(d.label, right - value_w - left - 6);
} else {
lv_obj_set_width(d.label, right - left);
}
drawn_count = (uint8_t) (slot + 1);
}
refresh();
if (!paged) return;
// The same pager as the tile pages, in the same place, so it is the one that is already learned.
uint8_t page_number = (uint8_t) (first_row / per_page + 1);
uint8_t page_total = (uint8_t) ((count + per_page - 1) / per_page);
int y = m.height - m.pager - m.bottom / 2, half = (m.width - 2 * m.pad) / 2 - 20;
for (int side = 0; side < 2; ++side) {
bool enabled = side ? page_number < page_total : page_number > 1;
if (!enabled) continue;
auto *bar = plain(root, side ? m.width - m.pad - half : m.pad, y, half, m.pager);
lv_obj_add_flag(bar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(bar, LV_OPA_TRANSP, 0);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_STATE_PRESSED);
lv_obj_set_style_bg_color(bar, lv_color_hex(0xDCDCDC), LV_STATE_PRESSED);
lv_obj_set_style_radius(bar, m.radius, 0);
auto *label = text(bar, side ? "Next >" : "< Previous", row_font, 0x1B1B1B,
side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
lv_obj_set_width(label, half - 12);
lv_obj_set_pos(label, 6, (m.pager - label_h) / 2);
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_SHORT_CLICKED,
(void *) (intptr_t) (side ? per_page : -per_page));
}
auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, 0x616161,
LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(counter, m.width);
lv_obj_set_pos(counter, 0, y + (m.pager - label_h) / 2);
}
inline void hide_hold_bar() {
if (!hold_bar) return;
lv_anim_delete(hold_bar, nullptr);
lv_obj_add_flag(hold_bar, LV_OBJ_FLAG_HIDDEN);
}
inline void open(uint8_t page = 0) {
hide_hold_bar();
current_page = page < PAGE_COUNT ? page : 0;
first_row = 0;
forget_confirm();
if (before_open) before_open();
if (!root) {
root = lv_obj_create(lv_screen_active());
lv_obj_remove_style_all(root);
lv_obj_set_size(root, lv_pct(100), lv_pct(100));
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); // nothing leaks through to the tiles below
lv_obj_set_style_bg_color(root, lv_color_hex(0xE7E7E7), 0);
lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0);
}
lv_obj_move_foreground(root);
draw();
}
inline void close() {
forget_confirm();
drawn_count = 0;
if (root) { lv_obj_delete(root); root = nullptr; }
}
// ---- holding the top bar opens the page ----
// Not a tile, not a button in view: a press of `hold_ms` on the strip with the screen name. A line
// grows along the top edge while you hold, so the gesture explains itself the first time it happens.
inline void hold_step(void *object, int32_t value) {
auto *bar = static_cast<lv_obj_t *>(object);
int width = lv_display_get_horizontal_resolution(lv_display_get_default());
if (drift && drift() > hold_limit) { hide_hold_bar(); return; }
// The first quarter second stays invisible: a tap must not flash a line across the screen.
if (value < 250) return;
lv_obj_remove_flag(bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_width(bar, std::max<int32_t>(1, (int32_t) width * (value - 250) / (int32_t) (hold_ms - 250)));
}
inline void hold_event(lv_event_t *event) {
auto code = lv_event_get_code(event);
if (!hold_bar) return;
if (code == LV_EVENT_PRESSED) {
if (visible() || (may_open && !may_open())) return;
lv_obj_set_width(hold_bar, 1);
lv_anim_t anim;
lv_anim_init(&anim);
lv_anim_set_var(&anim, hold_bar);
lv_anim_set_values(&anim, 0, (int32_t) hold_ms);
lv_anim_set_duration(&anim, (uint32_t) hold_ms);
lv_anim_set_exec_cb(&anim, hold_step);
lv_anim_set_completed_cb(&anim, [](lv_anim_t *) {
hide_hold_bar();
if (drift && drift() > hold_limit) return;
if (may_open && !may_open()) return;
open();
});
lv_anim_start(&anim);
return;
}
hide_hold_bar();
}
// A transparent strip over the top bar plus the line that fills while it is held. Both belong to the
// page below, so they survive every redraw of the tiles.
inline void attach_hold(lv_obj_t *page, int x, int y, int width, int height) {
bool large = lv_display_get_horizontal_resolution(lv_display_get_default()) >= 480;
hold_area = plain(page, x, y, width, height);
lv_obj_add_flag(hold_area, LV_OBJ_FLAG_CLICKABLE);
// No pressed style at all: a repaint of a strip this wide costs a frame that the touch polling on a
// resistive panel needs (the lesson of firmware 0.2.35).
lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_PRESSED, nullptr);
lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_RELEASED, nullptr);
lv_obj_add_event_cb(hold_area, hold_event, LV_EVENT_PRESS_LOST, nullptr);
hold_bar = plain(page, 0, 0, 1, large ? 5 : 3);
lv_obj_set_style_bg_opa(hold_bar, LV_OPA_COVER, 0);
lv_obj_set_style_bg_color(hold_bar, lv_color_hex(0x009FE3), 0);
lv_obj_add_flag(hold_bar, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(hold_bar);
}
} // namespace settings_screen
#endif
@@ -187,6 +187,9 @@ inline constexpr Named NAMES[] = {
{"weather-sunset-down", 0xF059B},
{"dots-horizontal", 0xF01D8},
{"arrow-oscillating", 0xF1C91},
{"monitor", 0xF0379},
{"information-outline", 0xF02FD},
{"restart", 0xF0709},
};
inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]);
// Codepoint of a name the fonts carry; 0 for any other name.
+38
View File
@@ -176,6 +176,44 @@ 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.52 / firmware 0.2.44
App and firmware. Storage version and tile protocol stay 1; everything is additive, so an old screen
with this app, and a new screen with an older app, both keep working.
- **Two settings, beside the frozen block.** `settings.auto_home` (bool, default `true`) and
`settings.auto_home_seconds` (30-3600, default 120) are new keys in `SETTING_RULES`, stored in
storage version 1. On the wire they travel as their own top-level keys of the layout message, next to
`swipe_pages` and `rotation`; the eleven-field `settings` object is unchanged, because firmware before
0.2.44 refuses any other size. `core.SETTINGS_BESIDE_BLOCK` is the one list of such keys and
`layout_message` uses it. Older firmware ignores the two keys.
- **Firmware storage.** A new preference key `0x484F4D31` holds `HomeTimeout` (two uint32: enabled,
seconds). The Settings structure `0x53435231`, swipe `0x53575031`, rotation `0x524F5431` and the CYD
calibration are untouched. `runtime_tiles::persist_settings()` saves all of them; ESPHome compares each
blob with NVS before writing, so unchanged records cost no flash write.
- **Settings page on the screen.** `components/smart_display/settings_screen.h`: one `constexpr` table of
pages and rows, drawn into a root object created on open and deleted on close. Opened by holding the
top bar (`attach_hold`, substitutions `SETTINGS_HOLD_X/W/DRIFT_PX`; on the Guition the strip keeps
clear of both swipe bands), by the `screen.settings` tile, or by the API action `open_settings`
(`page` 0-4, negative closes and goes home). A change is saved, applied (`apply_screen_settings`) and
reported with the existing `esphome.screen_setting` event; the manager's handler accepts every key in
`SETTING_RULES`, the new ones included. Row taps use the board's own drift limit
(`TOUCH_MOVE_LIMIT_PX`), the hold its own.
- **Back to page 1 by itself.** The 1 s interval runs the new `go_home` script (settings page closed,
cards closed, page 1) when `auto_home` is on and nothing touched the screen for `auto_home_seconds`,
while a card, the settings page or a later page is showing. Standby now always closes the settings page;
an alert closes it too.
- **`screen.settings` tile.** A second built-in entity next to `screen.clock`. `min_firmware` asks for
0.2.44, so the manager does not send a layout with it to older firmware. `Tile::is_clock()` now marks
the clock-only paths (minute redraw, second hand) that used `builtin()`; the editor mockup takes the
icon from `tile_icons.BUILTIN_TILES`.
- **Glyphs.** `monitor`, `information-outline` and `restart` join `tile_icons.FIXED` (183 glyphs).
- **API.** `open_settings` is a new ESPHome action; Home Assistant lists it as
`esphome.<screen>_open_settings` after the device reconnects with 0.2.44. The `IP address` text sensor
gained `id: wifi_ip`; its entity is unchanged.
- **Docs.** docs/SETTINGS.md is the recipe for adding a setting; the Claude skill gains a section on the
page (one more YAML example), so an installed skill shows as outdated until it is installed again.
### Compatibility 0.2.51 / firmware 0.2.43
App only; no firmware change, no protocol change, no storage change. Layouts keep their shape, so an
+98
View File
@@ -0,0 +1,98 @@
# Settings: on the screen and in ESP Screens
Every screen setting lives in two places at once: the **Screen settings** block of the add-on editor, and
the **settings page on the screen itself** (firmware 0.2.44+). This page says what that page is made of,
and exactly what adding one more setting takes.
## What the user sees
Holding the top bar of the overview for about one and a half seconds opens the page; a blue line grows
along the top edge while you hold, and letting go before it finishes cancels. A screen can also carry a
`screen.settings` tile, and Home Assistant can open it with `esphome.<screen>_open_settings`.
The page is a menu of groups, each of which opens a page of its own:
| Group | Rows |
|---|---|
| Brightness | Brightness, Auto standby, Standby after, Standby brightness |
| Night | Night mode, Starts, Ends, Night brightness |
| Screen | Clock, Back to page 1, After, Also on standby, Swipe between pages, Rotation (boards that turn) |
| This screen | Screen, Address, Firmware, Home Assistant, Restart |
Every change is saved on the screen, applied at once, and sent to the add-on as an
`esphome.screen_setting` event, so the editor shows the same value within a second. The add-on remains
the owner: its next layout message repeats every setting, and a screen that was offline catches up then.
## The rules the page follows
- **Two levels, never three.** A group page is the deepest place a setting can live.
- **No free scrolling.** A group that does not fit gets the same `< Previous / Next >` pager as the tile
pages. A 320x240 board shows five rows, a 480x480 board six.
- **A row is a control, not a form.** Toggles flip on tap, numbers and times have `-` and `+`, a choice
cycles through its options in a chip on the right.
- **A row that depends on a switch above it is greyed out, not hidden**, so the page never jumps around.
- **Only settings that belong to this piece of glass.** Tiles, the top bar and pages belong to the editor,
which has a mouse and a keyboard; the screen gets what you want to change while standing in front of it.
## Adding a setting
The example below adds "Beep on touch" (`beep`), a switch.
### 1. Where the value lives
Two homes, and the choice is fixed by history:
- `screen_settings::Settings` in `components/smart_display/screen_settings.h` is the **frozen** block of
eleven values that ESP Screens sends as `settings`. Its format is version 1 and changing it needs a
preference migration *and* firmware-version gating in the add-on, because older firmware refuses a
`settings` object that does not have exactly its own keys. Don't.
- Anything new is a plain value in `settings_screen.h` next to `swipe_pages`, `rotation` and `auto_home`,
with its own preference and its own key in the layout message. That is backward and forward
compatible in both directions: old firmware ignores the key, a new screen with an old add-on keeps its
saved value.
```cpp
// settings_screen.h, next to the others
inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120, beep = 0;
```
Storing it (`components/smart_display/runtime_tiles.h`): extend `HomeTimeout`-style records or add one of
your own, load it in `load_settings()`, write it in `persist_settings()`, and accept the key in
`receive()` next to `auto_home` — validate first, then save.
### 2. The row
One line in the table of the group it belongs to, in `settings_screen.h`:
```cpp
toggle("Beep on touch", [] { return beep; },
[](int32_t value) { beep = value ? 1 : 0; changed("beep", beep); }),
```
`changed(key, value)` saves, applies and reports in one go; `key` must be the add-on's key exactly.
The row kinds are `toggle`, `number` (fixed step, optional unit), `duration` (seconds, steps grow with
the value), `moment` (minutes since midnight, quarters by tap and hours while held), `choice`, `info`
and `action`. A row can carry `shown` (leave it out on boards that lack the hardware) and `enabled`
(grey while the switch it depends on is off).
Watch the count: a group of more than five rows gets a pager on a 320x240 board. Six is the maximum
that still fits a 480x480 board in one go.
### 3. The add-on
- `screen_manager/app/core.py`: one line in `SETTING_RULES` (`'beep': (False, None, None)`), which gives
you validation, storage and the write-back from the screen for free.
- `screen_manager/app/server.py`: add the key to `extra` in `layout_message` and send it as its own key,
next to `auto_home`. Never let it into the `settings` block.
- `screen_manager/app/static/app.js`: one line in `settingDefinitions` for the editor's own control.
- Needs new firmware? `min_firmware()` in `core.py` keeps the screen from being told about something it
cannot do.
### 4. Tests and proof
- `tests/test_settings_screen.cpp` (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`) walks every page and
every row: a new row is checked by the table test as soon as it exists.
- `tests/test_settings_view.py` and `tests/test_layout.py` cover the add-on side.
- Render it before believing it: `.esphome/readme-render/host_build.py <board> --compile` builds the real
firmware for the Mac and `render_settings.py <board>` drives it over the API and saves PNGs of every
page, including the hold gesture, the time picker and the settings tile.
+70
View File
@@ -0,0 +1,70 @@
# Test results app 0.2.52 / firmware 0.2.44 (2026-09-16)
The settings page on the screen itself, the `screen.settings` card, and "back to page 1 by itself".
Firmware and app both change; the protocol grows two optional keys and the stored layouts gain two
settings with defaults, so an old screen with the new app and a new screen with an old app both keep
working. See CHANGELOG 0.2.52 and docs/SETTINGS.md.
## Automated
- Python (`.venv-portal`): 200 tests OK. New `tests/test_settings_page.py` (7 tests): every key the
firmware reports back (`changed("...")` in settings_screen.h) is a key the add-on validates; the
`settings` block the screen receives still holds exactly its eleven frozen keys while `auto_home`
and `auto_home_seconds` ride beside it; the defaults and the refusals of the two new settings;
`screen.settings` as a built-in tile that asks for firmware 0.2.44; and both board profiles carrying
the page, the hold strip (clear of the Guition's swipe bands), the `open_settings` action and the
`go_home` script.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 14/14 PASS, including new
`tests/test_settings_screen.cpp`: the duration ladder and its symmetry, a number stopping at its
ends, a time wrapping around midnight and walking whole hours while a key is held, every text
("45 sec", "1 h 30", "22:00", "10:00 PM"), the rows reading and writing the real settings (including
a lower Brightness pulling the two dim levels down), a row hidden on a board that cannot turn, the
table itself (every menu row opens a real page, every control has a reader and a writer), how many
rows fit per board and when the pager appears, and that one change stores, applies and reports once.
- `generate_packages.py --check` green, `generate_icons.py --check` green (183 glyphs: three new).
- `esphome config` green for both board profiles.
## Builds (ESPHome 2026.6.2)
Full board profiles compiled for the ESP32, not just the host:
| Profile | RAM | Flash |
|---|---|---|
| home-like-2432s028.yaml (CYD) | 23.0% (75,492 B) | 80.6% (1,479,167 B of 1,835,008) |
| guition-4848s040.yaml | 24.0% (78,732 B) | 19.4% (1,576,143 B of 8,126,464) |
| easy-cyd-device.yaml (package, local components) | 23.0% (75,276 B) | 76.0% (1,395,143 B) |
| easy-guition-device.yaml (package, local components) | 24.0% (78,508 B) | 18.4% (1,494,075 B) |
The two Easy profiles were compiled one after the other, as RELEASING asks, from packages generated
out of the board profiles above.
What the page costs, measured by building the same CYD profile at 0.2.51 (firmware 0.2.43) in a clean
worktree: **+608 bytes of RAM** (74,884 -> 75,492) and **+10.7 KB of flash** (1,468,191 -> 1,479,167).
The rows are a `constexpr` table in flash and the LVGL objects are built when the page opens and
deleted when it closes, like the cards of a tile, so a closed page costs only that handful of bytes.
The ESP32 build also caught what the Mac could not: on xtensa `int32_t` is `long`, so a row reader
that returns a plain `int` does not convert to `Read` at all. Every reader now says `-> int32_t`.
## Rendered on the real firmware (host build, SDL)
`.esphome/readme-render/host_build.py <board> --compile` builds the actual firmware for the Mac and
`render_settings.py <board>` drives it over its own API. Both boards were rendered and looked at:
- Every page on both boards: the menu, Brightness, Night, Screen, This screen.
- Holding the top bar: the blue line is halfway after 0.9 s of a 1.5 s hold and disappears on release.
- The time picker worked like a finger: four taps on `+` moved 22:00 to 23:00 (quarters), then holding
walked whole hours to 03:00. Switching the clock to 12 hour turned the same times into 3:00 AM and
7:00 AM.
- A switch row turning its dependent rows live and grey again (Night mode, Auto standby).
- The `screen.settings` card on the overview, and a tap on it opening the menu.
- "Back to page 1 by itself": with the last touch aged past the timeout, the open page closed and the
overview came back on page 1.
## Not tested / for the owner
- Hardware. Nothing was flashed: the hold gesture on real glass (resistive CYD and capacitive Guition),
whether 1.5 s feels right, and whether a hold that becomes a swipe cancels cleanly.
- The write-back to a running ESP Screens (`esphome.screen_setting` event), which the firmware already
used for the Home Assistant number entities and which this page reuses unchanged.
- `Restart` from the page, and `esphome.<screen>_open_settings` from Home Assistant.
+76 -1
View File
@@ -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.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# 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"
@@ -66,6 +66,11 @@ substitutions:
TILE_W: "218"
TILE_H: "108"
HEADER_Y: "22"
# Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe
# that starts up there still changes the page.
SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}"
SETTINGS_HOLD_W: "416"
SETTINGS_HOLD_DRIFT_PX: "32"
# Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer.
ALERT_CARD_W: "420"
@@ -123,6 +128,7 @@ esphome:
- <esphome/components/smart_display/guition_diagnostics.h>
- <esphome/components/smart_display/backlight_fade.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -231,6 +237,31 @@ esphome:
if (kind == 'f') id(set_climate_fan_mode).execute(index);
else id(set_climate_swing_mode).execute(index);
};
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -333,6 +364,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -431,6 +475,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -881,6 +938,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
@@ -966,6 +1026,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3513,6 +3575,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3530,6 +3601,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3648,6 +3721,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();
+76 -1
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# 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.
@@ -97,6 +97,11 @@ substitutions:
TILE_W: "147"
TILE_H: "52"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_W: "${DISPLAY_W}"
SETTINGS_HOLD_DRIFT_PX: "24"
OVERLAY_SLIDER_W: "96"
OVERLAY_SLIDER_H: "160"
OVERLAY_ICON_Y: "74"
@@ -259,6 +264,7 @@ esphome:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -374,6 +380,31 @@ esphome:
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -470,6 +501,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -568,6 +612,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -1019,6 +1076,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: 'fonts/materialdesignicons-webfont.ttf'
id: materialdesign_icons_mini
@@ -1099,6 +1159,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3558,6 +3620,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3575,6 +3646,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3689,6 +3762,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();
+76 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# 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.
@@ -98,6 +98,11 @@ substitutions:
TILE_W: "147"
TILE_H: "52"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_W: "${DISPLAY_W}"
SETTINGS_HOLD_DRIFT_PX: "24"
OVERLAY_SLIDER_W: "96"
OVERLAY_SLIDER_H: "160"
OVERLAY_ICON_Y: "74"
@@ -263,6 +268,7 @@ esphome:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -378,6 +384,31 @@ esphome:
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -474,6 +505,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -557,6 +601,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -1008,6 +1065,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -1088,6 +1148,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3547,6 +3609,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3564,6 +3635,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3678,6 +3751,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();
+76 -1
View File
@@ -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.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# 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"
@@ -67,6 +67,11 @@ substitutions:
TILE_W: "218"
TILE_H: "108"
HEADER_Y: "22"
# Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe
# that starts up there still changes the page.
SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}"
SETTINGS_HOLD_W: "416"
SETTINGS_HOLD_DRIFT_PX: "32"
# Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer.
ALERT_CARD_W: "420"
@@ -127,6 +132,7 @@ esphome:
- <esphome/components/smart_display/guition_diagnostics.h>
- <esphome/components/smart_display/backlight_fade.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -235,6 +241,31 @@ esphome:
if (kind == 'f') id(set_climate_fan_mode).execute(index);
else id(set_climate_swing_mode).execute(index);
};
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -337,6 +368,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -420,6 +464,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -870,6 +927,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -955,6 +1015,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3502,6 +3564,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3519,6 +3590,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3637,6 +3710,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();
+11
View File
@@ -1,3 +1,14 @@
## 0.2.52 (firmware 0.2.44)
Change a screen's settings on the screen itself.
- **A settings page on the glass.** Hold the top bar of the overview for about a second and a half -- a blue line fills along the top edge while you hold -- and the screen opens its own settings: Brightness, Night, Screen and This screen. Toggles flip on a tap, numbers and times have `-` and `+` (hold them and a time walks whole hours), and a choice like the clock cycles in a chip. A change is saved on the screen, takes effect at once and appears in ESP Screens within a second, so both sides always show the same value.
- **Rotation and swiping are on it too.** Everything you would want to change standing in front of the panel: brightness, standby, night mode and its hours, the 12/24-hour clock, swiping between pages and, on boards that can, the rotation. Tiles and the top bar stay in the editor, where there is a mouse.
- **This screen.** Its name, IP address, firmware version, whether Home Assistant is connected, and a Restart that asks once before it does it.
- **Back to page 1 by itself.** New setting, on by default at two minutes: a card someone opened, or a second page they left behind, closes by itself after that long without a touch. Until now that only happened when the screen went into standby, so with standby off or far away a card could stay up all day. Set the time in ESP Screens or on the screen, or switch it off there.
- **A Settings tile.** Next to the clock card there is now a `Settings` card you can put on a page, for a screen where holding the bar is not obvious. Needs firmware 0.2.44.
- **For Home Assistant.** `esphome.<screen>_open_settings` opens the page (0 menu, 1 Brightness, 2 Night, 3 Screen, 4 This screen, -1 closes it and goes back to page 1), and the Claude skill explains all of it.
## 0.2.51 (firmware 0.2.43)
Ask Claude in Home Assistant to put something on a screen.
+4
View File
@@ -26,3 +26,7 @@ After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
**Tile settings** has click behavior, larger values and mini-sliders.
**Screen settings** has brightness and standby. **Inspector** helps with
missing attributes or an offline screen. New cards require firmware 0.2.0.
The screen carries the same settings itself (firmware 0.2.44 or newer): hold the top bar for
about a second and a half, or put the built-in **Settings** card on a page. What is changed
there appears here within a second, and the other way around.
+19 -1
View File
@@ -14,7 +14,8 @@ import zipfile
import tile_icons
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
CONTROLS, DISPLAYS, MAX_PAGES, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT)
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
TILE_EVENTS, TILE_RESULT_EVENT)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
@@ -298,6 +299,23 @@ actions:
```
Replace the person, light, window and screen with the real entity IDs (ask which screens when there are several), and leave out conditions the user did not ask for.
## The settings page on the screen itself
Firmware {SETTINGS_PAGE_MIN_FIRMWARE} or newer carries a settings page the user can open at the panel: hold the top bar (the strip with the screen's name and the clock) for about a second and a half, until the blue line along the top edge is full. A screen can also carry a tile for it: put the entity `screen.settings` on a screen like any other tile.
It holds Brightness, Night, Screen (clock, going back to page 1, swiping, rotation) and This screen (name, address, firmware, whether Home Assistant is connected, and Restart). A change there is saved on the screen and shows up in ESP Screens within a second, exactly like a change made from Home Assistant.
You can open it for someone who is standing at the panel:
```yaml
actions:
- action: esphome.kitchen_screen_open_settings
data:
page: 1
```
`page` 0 is the menu, 1 Brightness, 2 Night, 3 Screen, 4 This screen, and -1 closes the page and puts the screen back on page 1. Use it to walk someone through a setting ("I opened Brightness on the kitchen screen"), not to change something yourself: for that, use the entities above, which do the same thing without anyone at the panel.
'''
def status(directory):
+15 -2
View File
@@ -11,15 +11,17 @@ import tile_icons
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Clock'}
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'}
NEW_DOMAINS = frozenset('sun timer person screen'.split())
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.43'
FIRMWARE_VERSION = '0.2.44'
# 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.
SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -165,7 +167,16 @@ SETTING_RULES = {
'home_on_standby': (False, None, None),
'swipe_pages': (False, None, None),
'rotation': (0, 0, 270),
# Back to page 1 by itself (firmware 0.2.44+): closes an open card and any page but the first
# after this many seconds without a touch. Older firmware ignores both keys.
'auto_home': (True, None, None),
'auto_home_seconds': (120, 30, 3600),
}
# Firmware before 0.2.44 accepts a `settings` object with exactly its own eleven keys and refuses any
# other size, so everything added after it travels as its own key in the layout message. Old firmware
# ignores a key it does not know; a new screen with an old add-on keeps what it saved itself.
# docs/SETTINGS.md walks through adding one.
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
@@ -250,6 +261,8 @@ def validate_header(data):
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
if any(t['entity'] == 'screen.settings' for t in layout['tiles']):
return (0, 2, 44)
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
return (0, 2, 16)
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
+6 -2
View File
@@ -15,7 +15,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BUILTIN, 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, 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 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, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
@@ -599,8 +599,12 @@ class Manager:
if 'pages' in layout:
message['pages'] = layout['pages']
if 'settings' in layout:
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')}
# `settings` is the fixed eleven-key block older firmware insists on; everything added
# later travels as its own key, which firmware that predates it simply ignores.
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in SETTINGS_BESIDE_BLOCK}
message['swipe_pages'] = layout['settings'].get('swipe_pages',False)
message['auto_home'] = layout['settings'].get('auto_home',True)
message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120)
if screen.get('board')=='guition':
message['rotation'] = layout['settings'].get('rotation',0)
# The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
+3
View File
@@ -92,6 +92,8 @@ const settingDefinitions = [
// Whether the clock shows is up to the top bar now; the add-on keeps show_clock in step for older firmware.
["clock_24h", "24-hour clock (off = 12 hour)", "check", true],
["home_on_standby", "Return to page 1 after standby", "check", false],
["auto_home", "Close cards and return to page 1 by itself (firmware 0.2.44+)", "check", true],
["auto_home_seconds", "Return to page 1 after", "minutes", 120, 1, 60],
["rotation", "Rotate screen (clockwise)", "rotation", 0],
["swipe_pages", "Swipe between pages (Guition: from the edge, firmware 0.2.24+; CYD: quick swipe, 0.2.7+)", "check", false],
];
@@ -647,6 +649,7 @@ function iconNamed(name) {
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
function automaticIcon(id) {
const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
if (icons.builtin?.[id]) return icons.builtin[id];
if (entity?.icon) return entity.icon;
if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy;
if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon;
Binary file not shown.
+7
View File
@@ -220,6 +220,10 @@ FIXED = (
('dots-horizontal', 'F01D8'),
# Swing row on the Guition climate card, Home Assistant's icon for swing modes (firmware 0.2.40+).
('arrow-oscillating', 'F1C91'),
# The settings page on the screen itself (firmware 0.2.44+): its menu and the restart row.
('monitor', 'F0379'),
('information-outline', 'F02FD'),
('restart', 'F0709'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.
@@ -230,6 +234,8 @@ GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
# The cards the screen brings itself: one icon per entity, not per domain.
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'}
FALLBACK = 'power'
CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down',
'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left',
@@ -250,6 +256,7 @@ def editor():
return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]}
for group, icons in GROUPS],
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
'builtin': {entity: GLYPHS[name] for entity, name in BUILTIN_TILES.items()},
'weather': {state: GLYPHS[name] for state, name in WEATHER.items()},
'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']},
# Glyphs of the direct controls on wide cards, so the mockup previews them.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.51"
version: "0.2.52"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+1 -1
View File
@@ -72,7 +72,7 @@ class SkillText(unittest.TestCase):
def test_every_yaml_example_parses(self):
import yaml
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
self.assertEqual(len(blocks), 7)
self.assertEqual(len(blocks), 8)
parsed = [yaml.safe_load(block) for block in blocks]
# Tiles first (0.2.51): putting one on a screen, and ordering a page.
self.assertEqual(parsed[0]['actions'][0]['event'], 'esp_screens_add_tile')
+1 -1
View File
@@ -233,7 +233,7 @@ class ParityTests(unittest.TestCase):
self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand)
self.assertIn('part_line(w,18,w.points+28,2,w.hand_width)', second_hand)
tick = TILES.split('inline void tick() {', 1)[1]
self.assertIn('t.builtin() && t.display=="analog")second_hand(w,now);', tick)
self.assertIn('t.is_clock() && t.display=="analog")second_hand(w,now);', tick)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
+3 -3
View File
@@ -8,7 +8,7 @@ import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings
from core import SETTINGS_BESIDE_BLOCK, discover, installation_yaml, packets, state_message, validate_layout, validate_settings
class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self):
@@ -125,11 +125,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
+112
View File
@@ -0,0 +1,112 @@
"""The settings page on the screen itself (app 0.2.52, firmware 0.2.44).
Two halves have to agree without ever seeing each other: the row table in settings_screen.h writes keys
that only mean something if ESP Screens knows them, and the eleven-key `settings` block older firmware
insists on may never grow. Both are checked here, against the real files.
"""
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import test_portal
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
validate_settings)
SCREEN = (ROOT / 'components/smart_display/settings_screen.h').read_text()
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
# The keys the firmware's own block still carries; everything newer travels as its own key.
FROZEN = {'standby_enabled', 'standby_seconds', 'brightness', 'standby_brightness', 'night_enabled',
'night_start', 'night_end', 'night_brightness', 'show_clock', 'clock_24h', 'home_on_standby'}
assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK)
class SettingsPage(unittest.TestCase):
def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self):
keys = set(re.findall(r'changed\("(\w+)"', SCREEN))
self.assertTrue(keys, 'no rows found in settings_screen.h')
self.assertLessEqual(keys, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self):
# Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer
# settings travel as their own keys.
self.assertEqual(re.search(r'if \(obj\.size\(\) != (\d+)\) return false;', RUNTIME).group(1), '11')
self.assertEqual(FROZEN, set(SETTING_RULES) & FROZEN)
def test_new_settings_default_and_validate(self):
clean = validate_settings({})
self.assertEqual(clean['auto_home'], True)
self.assertEqual(clean['auto_home_seconds'], 120)
self.assertEqual(validate_settings({'auto_home_seconds': 30})['auto_home_seconds'], 30)
for bad in ({'auto_home_seconds': 29}, {'auto_home_seconds': 3601}, {'auto_home': 1},
{'auto_home_seconds': True}):
with self.assertRaises(ValueError):
validate_settings(bad)
def test_the_settings_tile_is_a_builtin_that_needs_new_firmware(self):
self.assertIn('screen.settings', BUILTIN)
self.assertTrue(entity_id('screen.settings'))
self.assertEqual(min_firmware({'tiles': [{'entity': 'screen.settings'}]}), (0, 2, 44))
self.assertLess(min_firmware({'tiles': [{'entity': 'screen.clock'}]}), (0, 2, 44))
layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'screen.settings', 'name': 'Settings'}]})
self.assertEqual(layout['tiles'][0]['entity'], 'screen.settings')
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class LayoutMessage(unittest.IsolatedAsyncioTestCase):
async def test_the_new_keys_ride_beside_the_frozen_block(self):
with tempfile.TemporaryDirectory() as temp:
manager = test_portal.ManagerTests().setup_manager(Path(temp) / 'screens.json')
layout = {'title': 'Home', 'tiles': [{'entity': 'light.a'}],
'settings': validate_settings({'auto_home': False, 'auto_home_seconds': 600})}
manager.save('text.screen', layout)
message = manager.layout_message('text.screen', manager.layouts['text.screen'], {})
self.assertEqual(set(message['settings']), FROZEN, 'older firmware refuses any other size')
self.assertEqual(message['auto_home'], False)
self.assertEqual(message['auto_home_seconds'], 600)
# A screen that never stored settings still gets the defaults, not a missing key.
plain = manager.layout_message('text.screen', {**manager.layouts['text.screen'],
'settings': validate_settings({})}, {})
self.assertEqual((plain['auto_home'], plain['auto_home_seconds']), (True, 120))
self.assertLess(len(json.dumps(message)), 4096, 'the screen refuses a message over 4 KB')
class Firmware(unittest.TestCase):
"""The board profiles have to carry the page, its way in, and the timer that closes it."""
def setUp(self):
self.profiles = {name: (ROOT / name).read_text()
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')}
def test_both_boards_wire_the_page_and_its_gesture(self):
for name, text in self.profiles.items():
self.assertIn('settings_screen::attach_hold', text, name)
self.assertIn('settings_screen::report', text, name)
self.assertIn('settings_screen::may_open', text, name)
self.assertIn('- action: open_settings', text, name)
self.assertIn('- id: go_home', text, name)
self.assertIn('runtime_tiles::auto_home', text, name)
self.assertIn('SCREEN_FIRMWARE_VERSION: "0.2.44"', text, name)
def test_the_hold_strip_covers_the_top_bar_and_no_more(self):
for name, text in self.profiles.items():
values = dict(re.findall(r'^ (\w+): "([^"]*)"', text, re.M))
width, scroll = int(values['DISPLAY_W']), int(values['SCROLL_Y'])
x = int(values['SETTINGS_HOLD_X'].replace('${EDGE_SWIPE_BAND_PX}', values.get('EDGE_SWIPE_BAND_PX', '0')))
hold = values['SETTINGS_HOLD_W'].replace('${DISPLAY_W}', values['DISPLAY_W'])
self.assertLessEqual(x + int(hold), width, name)
self.assertGreater(scroll, 20, name)
if 'EDGE_SWIPE_BAND_PX' in values:
# The page swipe owns both edge bands; the hold may not start there.
band = int(values['EDGE_SWIPE_BAND_PX'])
self.assertGreaterEqual(x, band, name)
self.assertLessEqual(x + int(hold), width - band, name)
if __name__ == '__main__':
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
#define SETTINGS_SCREEN_TEST
#include "../components/smart_display/settings_screen.h"
#include <cassert>
#include <string>
using namespace settings_screen;
// The row of a page by its label, so a test says what it means.
static const Row &row_named(const Page &page, const std::string &label) {
for (uint8_t i = 0; i < page.count; ++i)
if (label == page.rows[i].label) return page.rows[i];
assert(false);
return page.rows[0];
}
int main() {
// ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ----
assert(ladder_step(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300);
assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800);
Row timeout = duration("After", nullptr, nullptr, 30, 3600);
assert(stepped(timeout, 120, 1) == 150 && stepped(timeout, 120, -1) == 90);
assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300);
assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 1) == 300);
assert(stepped(timeout, 30, -1) == 30 && stepped(timeout, 3600, 1) == 3600);
assert(at_end(timeout, 30, -1) && at_end(timeout, 3600, 1) && !at_end(timeout, 120, -1));
// ---- a number stops at its ends, a moment walks around midnight ----
Row percent = number("Brightness", nullptr, nullptr, 5, 100, 5, "%");
assert(stepped(percent, 100, 1) == 100 && stepped(percent, 5, -1) == 5 && stepped(percent, 95, 1) == 100);
Row start = moment("Starts", nullptr, nullptr);
assert(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425);
assert(!at_end(start, 0, -1));
// Holding the key walks whole hours, and first lands on the hour it is in.
assert(stepped(start, 1320, 1, true) == 1380 && stepped(start, 1320, -1, true) == 1260);
assert(stepped(start, 1327, 1, true) == 1380 && stepped(start, 1327, -1, true) == 1320);
assert(stepped(start, 1380, 1, true) == 1440 % 1440); // 23:00 + 1 h wraps to 00:00
assert(stepped(start, 0, -1, true) == 1380); // 00:00 - 1 h is 23:00
// ---- texts ----
assert(duration_text(45) == "45 sec" && duration_text(60) == "1 min" && duration_text(120) == "2 min");
assert(duration_text(3600) == "1 h" && duration_text(5400) == "1 h 30" && duration_text(86400) == "24 h");
assert(moment_text(1320, true) == "22:00" && moment_text(0, true) == "00:00");
assert(moment_text(1320, false) == "10:00 PM" && moment_text(0, false) == "12:00 AM");
assert(moment_text(750, false) == "12:30 PM" && moment_text(60, false) == "1:00 AM");
// ---- rows read and write the real settings ----
screen_settings::current = screen_settings::Settings{};
const Page &light = pages[1];
const Row &brightness = row_named(light, "Brightness");
assert(value_text(brightness) == "100%");
brightness.write(40);
assert(screen_settings::current.brightness == 40 && value_text(brightness) == "40%");
// Lowering the normal brightness pulls the two dim levels down with it, exactly like the add-on.
screen_settings::current.standby_brightness = 60;
brightness.write(30);
assert(screen_settings::current.standby_brightness == 30);
const Row &standby = row_named(light, "Standby brightness");
standby.write(90);
assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness
assert(screen_settings::current.valid());
const Page &screen = pages[3];
const Row &clock = row_named(screen, "Clock");
assert(clock.option_count == 2 && value_text(clock) == "24 hour");
clock.write(0);
assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour");
const Row &home = row_named(screen, "Back to page 1");
home.write(0);
assert(auto_home == 0 && value_text(home) == "Off");
const Row &rotate = row_named(screen, "Rotation");
rotation_supported = false;
assert(!visible_row(rotate)); // a board that cannot turn never shows the row
rotation_supported = true;
assert(visible_row(rotate));
rotate.write(2);
assert(rotation == 180 && value_text(rotate) == "180°");
rotate.write(3);
assert(rotation == 270);
rotate.write(0);
assert(rotation == 0);
// ---- every page hangs together: menu rows open a real page, controls can be worked ----
assert(PAGE_COUNT == 5);
for (uint8_t p = 0; p < PAGE_COUNT; ++p) {
const Page &page = pages[p];
assert(page.title && page.count && page.count <= 8);
for (uint8_t i = 0; i < page.count; ++i) {
const Row &row = page.rows[i];
assert(row.label && *row.label);
if (row.kind == Kind::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon);
if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number ||
row.kind == Kind::duration || row.kind == Kind::moment)
assert(row.read && row.write);
if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low);
if (row.kind == Kind::duration) assert(row.high > row.low);
if (row.kind == Kind::choice) assert(row.options && row.option_count);
if (row.kind == Kind::info) assert(row.text);
if (row.kind == Kind::action) assert(row.run && *row.icon);
}
}
// Every page is reachable from the menu.
bool reached[PAGE_COUNT] = {true};
for (uint8_t i = 0; i < pages[0].count; ++i) reached[pages[0].rows[i].opens] = true;
for (bool page : reached) assert(page);
// ---- how many rows fit, and when the pager appears ----
bool paged = false;
assert(fitting_rows(180, 34, 4, 26, 4, paged) == 4 && !paged); // CYD: four rows, no pager
assert(fitting_rows(180, 34, 4, 26, 6, paged) == 4 && paged); // six rows: 4 + 2
assert(fitting_rows(372, 52, 8, 40, 6, paged) == 6 && !paged); // Guition: the whole group at once
assert(fitting_rows(372, 52, 8, 40, 9, paged) == 5 && paged);
assert(fitting_rows(40, 34, 4, 26, 5, paged) >= 1); // never zero rows
// ---- a change is stored, applied and reported once ----
static int stored = 0, applied = 0;
static std::string last_key;
static int32_t last_value = 0;
store = [] { ++stored; };
apply = [] { ++applied; };
report = [](const char *key, int32_t value) { last_key = key; last_value = value; };
row_named(light, "Auto standby").write(0);
assert(stored == 1 && applied == 1 && last_key == "standby_enabled" && last_value == 0);
row_named(screen, "After").write(600);
assert(stored == 2 && last_key == "auto_home_seconds" && last_value == 600 && auto_home_seconds == 600);
}
+9 -1
View File
@@ -48,7 +48,15 @@ class IconSetTests(unittest.TestCase):
for domains, code in re.findall(r'if \((d == "\w+"(?: \|\| d == "\w+")*)\) return "\\U000(F[0-9A-F]{4})";', body):
for domain in re.findall(r'"(\w+)"', domains):
native[domain] = code
self.assertEqual(native, {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items()})
# The built-in cards pick per entity, not per domain: a clock or the settings page.
settings, clock = re.search(
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
builtin = {entity: tile_icons.GLYPHS[name] for entity, name in tile_icons.BUILTIN_TILES.items()}
self.assertEqual({'screen.settings': settings, 'screen.clock': clock}, builtin)
self.assertEqual(tile_icons.editor()['builtin'], builtin)
self.assertEqual(tile_icons.GLYPHS[tile_icons.DEFAULTS['screen']], clock)
expected = {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items() if domain != 'screen'}
self.assertEqual(native, expected)
self.assertIn(f'return "\\U000{tile_icons.GLYPHS[tile_icons.FALLBACK]}";', body)
weather = HEADER.split('inline const char *weather_icon(const std::string &condition) {', 1)[1].split('\n}', 1)[0]
for state, name in tile_icons.WEATHER.items():