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:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user