Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# The release checks of docs/RELEASING.md step 2 on every push and pull request to main (app 0.2.78). Both jobs run
|
||||
# tools/check.sh, the same script a release runs on a laptop, so CI and a local run can't drift apart.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
# What the add-on image adds to ESPHome's (screen_manager/Dockerfile), Pillow for the camera tests (ESPHome
|
||||
# brings it in the image) and fontTools for tools/generate_icons.py.
|
||||
- name: Python packages
|
||||
run: python -m pip install 'aiohttp>=3.12,<4' PyYAML Pillow fonttools
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: clang
|
||||
run: clang++ --version || (sudo apt-get update && sudo apt-get install -y clang)
|
||||
- name: tools/check.sh
|
||||
run: bash tools/check.sh
|
||||
env:
|
||||
PYTHON: python
|
||||
CXX: clang++
|
||||
|
||||
firmware:
|
||||
name: firmware
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
# The ESPHome the add-on ships and builds every screen's update with.
|
||||
- name: ESPHome version of the add-on
|
||||
id: esphome
|
||||
run: echo "version=$(sed -n 's|^FROM ghcr.io/esphome/esphome:||p' screen_manager/Dockerfile)" >> "$GITHUB_OUTPUT"
|
||||
- name: ESPHome
|
||||
run: python -m pip install "esphome==${{ steps.esphome.outputs.version }}"
|
||||
# PlatformIO's toolchains and ESP-IDF, and the two check builds (tools/check.sh builds under .esphome/check).
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.platformio
|
||||
.esphome/check
|
||||
key: firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-${{ hashFiles('home-like-2432s028.yaml', 'guition-4848s040.yaml', 'components/**') }}
|
||||
restore-keys: |
|
||||
firmware-${{ runner.os }}-esphome-${{ steps.esphome.outputs.version }}-
|
||||
- name: tools/check.sh --firmware
|
||||
run: bash tools/check.sh --firmware
|
||||
env:
|
||||
PYTHON: python
|
||||
@@ -8,7 +8,8 @@ Every push to GitHub is a release: always also bump the add-on version in
|
||||
screen_manager/config.yaml (with a CHANGELOG line), otherwise HA won't see an update.
|
||||
Generate packages with tools/generate_packages.py; don't edit them by hand.
|
||||
A screen gets its tiles from the add-on while it runs: every card works in all
|
||||
twenty positions, and no Home Assistant entity belongs in a board profile.
|
||||
48 positions (eight pages of six, firmware 0.2.62+), and no Home Assistant entity
|
||||
belongs in a board profile.
|
||||
Preserve the data schema, protocol compatibility, unique keys, and CYD preferences.
|
||||
Test updates against existing data. Don't publish an unknown storage version without
|
||||
a migration. Production Ingress needs no long-lived token or public port.
|
||||
@@ -75,8 +76,10 @@ and screens for lookups and tests. If that file is missing you are not on his ma
|
||||
filtered physical ADC values. Don't apply the affine correction twice, in both the driver and the UI.
|
||||
- The calibration wizard assumes swap_xy=false, mirror_x=true, mirror_y=false,
|
||||
and LVGL 90°. A changed orientation also requires a new projection/tests.
|
||||
- Run the Python tests, both C++ test suites, and ESPHome validation/build on code changes.
|
||||
Firmware tests and hardware acceptance are different checks.
|
||||
- Run `tools/check.sh` on code changes (the Python tests, every C++ test, both generators, the
|
||||
editor's tests, types and build); on a firmware change also `tools/check.sh --firmware`, which
|
||||
compiles both boards and applies the CYD's flash budget (docs/RELEASING.md step 2). CI runs the
|
||||
same script. Firmware tests and hardware acceptance are different checks.
|
||||
- `diagnostics/run_ui_test.py` renders without HA actions; don't touch the screen
|
||||
during that test. Use `--name` for the expected device identity.
|
||||
`diagnostics/send_layout.py` pushes a demo layout with every card type to a
|
||||
|
||||
+14
-10
@@ -40,12 +40,15 @@ on the screen itself, and how updates work.
|
||||
double-width card, a **graph** of the sensor history in the tile,
|
||||
a **sun path** (`sun.sun`: horizon with the sun between sunrise and
|
||||
sunset), a **timer** (`timer.*`, tapping starts or pauses it), and
|
||||
**presence** (`person.*`). Pick them in the picker like any other tile, or
|
||||
**presence** (`person.*`). Pick them in the library like any other tile, or
|
||||
drag them straight into the screen mockup; **Double-width** is an option for every tile, and so is
|
||||
**Full page** (firmware 0.2.62+): the tile takes the whole page and is one big button that lights up
|
||||
in its state colour while on, so a screen by the door switches the light when you push anywhere on it.
|
||||
Its small slider, direct controls or graph sit at the bottom of the page. A **Go to page** tile
|
||||
(`screen.page_2` to `screen.page_8`) opens another page: a full-page light switch on page 1, a menu on page 2.
|
||||
(`screen.page_1` to `screen.page_8`) opens another page: a full-page light switch on page 1, a menu on page 2.
|
||||
Under **Goes to page** the editor offers the pages the screen has and the next, empty one, where a new sub-page
|
||||
starts. From firmware 0.2.65 the same page tile may sit on several pages of a screen, such as a tile back to the
|
||||
menu on every sub-page; older firmware takes each page tile once.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/guition-full-light.png" width="32%" alt="A full-page tile on the Guition: one big amber light switch">
|
||||
@@ -114,8 +117,8 @@ on the screen itself, and how updates work.
|
||||
- **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.
|
||||
- **Inspector:** check entities, status, and configuration in ESP Screens.
|
||||
Action feedback shows that a command is on its way.
|
||||
- **Read current data** (the ··· menu of a screen): what Home Assistant reports for every tile right now,
|
||||
and how each tile is set.
|
||||
- **Override YAML per screen** (app 0.2.61): your own ESPHome YAML for one screen, such as another
|
||||
display controller, kept through updates. See
|
||||
[Updates and keeping your settings](#updates-and-keeping-your-settings).
|
||||
@@ -167,9 +170,10 @@ The app must keep running to keep the screens supplied with current data.
|
||||
|
||||
A short tap on a switch switches immediately; the off state gets a gray icon. A long press
|
||||
opens its history card with the toggle at the top right (firmware 0.2.51+).
|
||||
The feedback stops as soon as Home Assistant reports the changed state, with a
|
||||
minimum of 150 ms for switches. Tiles with a mini-slider keep their
|
||||
icon; on the CYD, the icon and text block are vertically centered.
|
||||
The tile shows the new state at once and Home Assistant's own report confirms it; a refusal
|
||||
puts the old state back. A spinner only appears when Home Assistant takes longer than about
|
||||
0.4 seconds, and no wait lasts longer than three (firmware 0.2.59+). Tiles with a mini-slider
|
||||
keep their icon; on the CYD, the icon and text block are vertically centered.
|
||||
|
||||
## Alert from an automation
|
||||
|
||||
@@ -208,7 +212,7 @@ data:
|
||||
night mode wait as long as the card is showing.
|
||||
- **`flash`**: `true` makes the backlight blink four times when the alert arrives.
|
||||
|
||||
In ESP Screens, **Settings → Alerts** opens a cheatsheet with the exact action name for each screen,
|
||||
In ESP Screens, **Alerts** in the sidebar opens a cheatsheet with the exact action name for each screen,
|
||||
a ready-to-paste example, and all fields, icons, and colors. Home Assistant asks for all seven fields; leave a field empty (`""`, `0`, `false`) if you
|
||||
don't use it. A new alert replaces the current one. Every end is reported as the event
|
||||
**`esphome.screen_alert`** with `action` (`ok`, `timeout`, `replaced`, or `remote`), `title`,
|
||||
@@ -315,7 +319,7 @@ the Wake and Sleep of the same screen included.
|
||||
|
||||
Click a tile in the screen preview. Under **Pastel background**, choose a color,
|
||||
such as red or green. Optionally adjust the name, click action, mini-slider, or large
|
||||
value. Click **Save & send to screen** to apply the changes.
|
||||
value. Click **Save & send** to apply the changes.
|
||||
After the first supporting firmware update, this requires no new flash.
|
||||
|
||||
<p align="center">
|
||||
@@ -416,7 +420,7 @@ with the built-in CLI, installs it wirelessly, and waits until the screen is bac
|
||||
With the checkbox enabled, that happens automatically at night, one screen at a time; a
|
||||
failure stops the round and posts a notification in Home Assistant. Firmware
|
||||
0.2.17+ reports its own device name and IP address for this; an older screen asks
|
||||
for the IP address once. You can still do it manually via Settings → Firmware & USB → Wi-Fi / OTA.
|
||||
for the IP address once. You can still do it manually via **Firmware & USB** in the sidebar → **Wi-Fi / OTA**.
|
||||
|
||||
The device's own YAML and Wi-Fi/API/OTA settings stay in the ESPHome config folder.
|
||||
Tile layouts and options live in the app's persistent data. CYD calibration and
|
||||
|
||||
@@ -29,9 +29,11 @@ class TouchGuard {
|
||||
public:
|
||||
enum Reject { NONE, MOVED, TOO_SHORT, USED, BOUNCE };
|
||||
// Board tuning, set from the profile on boot: how far the finger may drift (pixels) before
|
||||
// a tap is dropped, 0 for no limit at all (LVGL then decides: releasing inside the tile is a
|
||||
// tap, leaving it is not), and the shortest contact that counts. A resistive panel (CYD)
|
||||
// bounces on landing and lift-off and needs both; a capacitive one (Guition) needs neither.
|
||||
// a tap is dropped, 0 for no limit at all, and the shortest contact that counts. A resistive
|
||||
// panel (CYD) bounces on landing and lift-off and needs both; a capacitive one (Guition) needs
|
||||
// neither. Without a limit a tile checks itself that the finger let go on it (runtime_tiles::event:
|
||||
// LVGL keeps the press on the tile and clicks it wherever the finger lets go), and a quick flick
|
||||
// is LVGL's gesture, which consume()s the contact (firmware 0.2.65+).
|
||||
void configure(int move_limit_px, uint32_t min_press_ms) {
|
||||
move_limit_ = move_limit_px;
|
||||
min_press_ = min_press_ms;
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
// The tiles of a screen live on the heap, as many as the layout has (firmware 0.2.62+): a screen with twelve
|
||||
// tiles pays for twelve. On a board with PSRAM ESPHome's allocator puts them there, so the internal RAM the
|
||||
// WiFi stack and LVGL need stays free; without PSRAM it falls back to the internal heap. The host tests
|
||||
// and the host render build have neither, so they take the standard allocator.
|
||||
// tiles pays for twelve. On a board with PSRAM ESPHome's allocator puts that list, the fixed part of every
|
||||
// tile, in PSRAM; without PSRAM it takes the internal heap. What a tile holds beyond it, its strings and its
|
||||
// Extra, comes from plain malloc and new, which ESPHome's psram setup (CONFIG_SPIRAM_USE_CAPS_ALLOC) keeps in
|
||||
// the internal heap on both boards. The host tests and the host render build have neither, so they take the
|
||||
// standard allocator.
|
||||
#if __has_include("esphome/core/defines.h")
|
||||
#include "esphome/core/defines.h"
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#endif
|
||||
|
||||
namespace runtime_tiles {
|
||||
@@ -25,6 +29,12 @@ constexpr size_t SLOTS_PER_PAGE = 6;
|
||||
constexpr size_t MAX_PAGES = 8;
|
||||
constexpr size_t MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
|
||||
constexpr size_t MAX_TILES = MAX_SLOTS;
|
||||
// One bit per tile for the cards the next render draws again (firmware 0.2.65+). Firmware 0.2.62-0.2.64 kept 32 bits
|
||||
// while a screen holds 48 tiles, so a state for tile 33 to 48 redrew the whole page. 0 beyond them: draw everything.
|
||||
static_assert(MAX_TILES <= 64, "one dirty bit per tile");
|
||||
constexpr uint64_t tile_bit(size_t index) { return index < 64 ? uint64_t{1} << index : 0; }
|
||||
// A navigation tile (screen.page_<n>, firmware 0.2.62+).
|
||||
inline bool page_entity(const std::string &entity) { return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0; }
|
||||
inline bool valid_entity(const std::string &entity) {
|
||||
if (entity.size() > 120) return false;
|
||||
auto dot = entity.find('.');
|
||||
@@ -34,10 +44,10 @@ 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; screen.page_<n> (firmware 0.2.62+)
|
||||
// only goes to page n, one such tile per page, so an entity still appears once on a screen.
|
||||
// only goes to page n. Several pages may each carry the same one (firmware 0.2.65+, Model::set_layout).
|
||||
if (domain == "screen") {
|
||||
if (entity == "screen.clock" || entity == "screen.settings") return true;
|
||||
return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0 && entity[12] >= '1' && entity[12] <= static_cast<char>('0' + MAX_PAGES);
|
||||
return page_entity(entity) && entity[12] >= '1' && entity[12] <= static_cast<char>('0' + MAX_PAGES);
|
||||
}
|
||||
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", "camera", "image"})
|
||||
if (domain == allowed) return true;
|
||||
@@ -260,7 +270,7 @@ struct Tile {
|
||||
bool is_clock() const { return entity == "screen.clock"; }
|
||||
bool is_settings() const { return entity == "screen.settings"; }
|
||||
// A navigation tile (screen.page_<n>, firmware 0.2.62+) and the page it goes to, counted from one.
|
||||
bool is_page() const { return entity.size() == 13 && entity.compare(0, 12, "screen.page_") == 0; }
|
||||
bool is_page() const { return page_entity(entity); }
|
||||
int page_target() const { return is_page() ? entity[12] - '0' : 0; }
|
||||
// Slots a tile takes: one, a row of two, or the six of a page.
|
||||
unsigned cells() const { return full ? SLOTS_PER_PAGE : wide ? 2u : 1u; }
|
||||
@@ -291,13 +301,22 @@ struct Tile {
|
||||
// Slot position of a tile within the fixed two-column, three-row pages.
|
||||
struct Placement { uint8_t page = 0, slot = 0; };
|
||||
#ifdef USE_ESP32
|
||||
// ESPHome's allocator prefers PSRAM and falls back to the internal heap; this wrapper only gives it the
|
||||
// comparison std::vector wants.
|
||||
// ESPHome's allocator prefers PSRAM and falls back to the internal heap; this wrapper gives it the comparison
|
||||
// std::vector wants. RAMAllocator answers nullptr when neither has room, and std::vector (no exceptions on the
|
||||
// ESP32) would write the tiles through it: set_layout checks the room first (tile_room), and anything that still
|
||||
// gets here stops with a log line instead of corrupting memory (firmware 0.2.65+).
|
||||
template<class T> struct TileAllocator {
|
||||
using value_type = T;
|
||||
TileAllocator() = default;
|
||||
template<class U> TileAllocator(const TileAllocator<U> &) {}
|
||||
T *allocate(size_t n) { return esphome::RAMAllocator<T>().allocate(n); }
|
||||
T *allocate(size_t n) {
|
||||
T *p = esphome::RAMAllocator<T>().allocate(n);
|
||||
if (!p) {
|
||||
ESP_LOGE("runtime", "No memory for %u tiles (%u bytes)", static_cast<unsigned>(n), static_cast<unsigned>(n * sizeof(T)));
|
||||
abort();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
void deallocate(T *p, size_t n) { esphome::RAMAllocator<T>().deallocate(p, n); }
|
||||
bool operator==(const TileAllocator &) const { return true; }
|
||||
bool operator!=(const TileAllocator &) const { return false; }
|
||||
@@ -306,6 +325,14 @@ using TileList = std::vector<Tile, TileAllocator<Tile>>;
|
||||
#else
|
||||
using TileList = std::vector<Tile>;
|
||||
#endif
|
||||
// The largest block the tile list could get: ESPHome's own answer on the ESP32 (PSRAM or the internal heap,
|
||||
// whichever has the larger one). Nothing on the host, which has room; the tests put in a figure of their own.
|
||||
#ifdef USE_ESP32
|
||||
inline size_t largest_tile_block() { return esphome::RAMAllocator<Tile>().get_max_free_block_size(); }
|
||||
inline size_t (*tile_room)() = largest_tile_block;
|
||||
#else
|
||||
inline size_t (*tile_room)() = nullptr;
|
||||
#endif
|
||||
// Wide tiles start in the left column and take the whole row; a right-column gap before them stays
|
||||
// empty. A full tile starts a page of its own; the slots it leaves behind stay empty. Returns the page
|
||||
// count (at least one).
|
||||
@@ -336,6 +363,9 @@ struct Model {
|
||||
size_t count = 0;
|
||||
std::string title = "Choose tiles in HA";
|
||||
bool configured = false;
|
||||
// Why the last set_layout refused a layout, for the manager's inbox status; empty when it took the layout or the
|
||||
// message itself was wrong (the status then says "invalid message", as before).
|
||||
std::string refusal;
|
||||
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed) {
|
||||
bool moved = false;
|
||||
return set_layout(entities, name, changed, {}, moved);
|
||||
@@ -344,9 +374,13 @@ struct Model {
|
||||
// positions, the pages re-place without touching states or an open card.
|
||||
bool set_layout(const std::vector<std::string> &entities, const std::string &name, bool &changed,
|
||||
const std::vector<uint8_t> &positions, bool &moved) {
|
||||
refusal.clear();
|
||||
if (entities.size() > MAX_TILES || name.size() > 96) return false;
|
||||
for (size_t i = 0; i < entities.size(); ++i) {
|
||||
if (!valid_entity(entities[i])) return false;
|
||||
// A Home Assistant entity appears once on a screen; a navigation tile may sit on several pages (firmware 0.2.65+).
|
||||
// Everything that reaches a tile goes by its index (Model::accepts), so each copy gets its own options.
|
||||
if (page_entity(entities[i])) continue;
|
||||
for (size_t j = 0; j < i; ++j) if (entities[i] == entities[j]) return false;
|
||||
}
|
||||
if (!positions.empty()) {
|
||||
@@ -360,6 +394,12 @@ struct Model {
|
||||
for (size_t i = 0; i < entities.size() && i < tiles.size(); ++i) if (tiles[i].entity != entities[i]) changed = true;
|
||||
moved = !changed && (explicit_slots != !positions.empty());
|
||||
for (size_t i = 0; i < positions.size() && !changed; ++i) if (slots[i] != positions[i]) moved = true;
|
||||
// A longer list needs one block that holds every tile. Without it the layout is refused before anything changes, so
|
||||
// the screen keeps the tiles it shows and the manager hears why (firmware 0.2.65+).
|
||||
if (entities.size() > tiles.capacity() && tile_room && tile_room() < entities.size() * sizeof(Tile)) {
|
||||
refusal = "Error: no memory for " + std::to_string(entities.size()) + " tiles";
|
||||
return false;
|
||||
}
|
||||
// A title-only update must not interrupt an open control card.
|
||||
title = name.empty() ? "Home" : name;
|
||||
if (changed) {
|
||||
|
||||
@@ -301,7 +301,13 @@ inline std::string receive(const std::string &payload) {
|
||||
bool changed = false, moved = false, was_configured = model.configured;
|
||||
const std::string previous_title = model.title;
|
||||
const uint8_t previous_pages = model.pages;
|
||||
if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) return false;
|
||||
if (!model.set_layout(entities, string(root["title"], 96), changed, positions, moved)) {
|
||||
if (!model.refusal.empty()) {
|
||||
ESP_LOGW("runtime", "Layout of %u tiles refused: %s", static_cast<unsigned>(entities.size()), model.refusal.c_str());
|
||||
result = model.refusal;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Empty pages the user keeps on purpose; absent on older managers.
|
||||
model.pages = root["pages"].is<unsigned>() ? static_cast<uint8_t>(std::clamp<unsigned>(root["pages"].as<unsigned>(), 1, MAX_PAGES)) : 1;
|
||||
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
|
||||
@@ -2084,11 +2090,29 @@ inline std::string date_text(const esphome::ESPTime &now) {
|
||||
if (!now.is_valid() || now.day_of_week < 1 || now.day_of_week > 7 || now.month < 1 || now.month > 12) return "";
|
||||
return weekday_text(now) + " " + std::to_string(now.day_of_month) + " " + months[now.month - 1];
|
||||
}
|
||||
// Where the finger is, or where it let go, in the screen's coordinates; false without a finger (a test's event).
|
||||
inline bool finger_at(lv_point_t &point) {
|
||||
auto *indev = lv_indev_active();
|
||||
if (!indev || lv_indev_get_type(indev) != LV_INDEV_TYPE_POINTER) return false;
|
||||
lv_indev_get_point(indev, &point);
|
||||
return true;
|
||||
}
|
||||
inline void event(lv_event_t *event) {
|
||||
auto &w = *static_cast<Widgets *>(lv_event_get_user_data(event));
|
||||
if (!enabled || w.index >= model.count) return;
|
||||
auto code = lv_event_get_code(event);
|
||||
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
|
||||
// A finger that slid off the card is not a tap or a hold on it (the Guition, firmware 0.2.65+). LVGL keeps the press
|
||||
// on the object it started on (LV_OBJ_FLAG_PRESS_LOCK, which LVGL 9.5 sets on every child) and clicks it on release
|
||||
// wherever the finger is. The CYD's drift limit already drops such a tap; the Guition has none (TOUCH_MOVE_LIMIT_PX 0,
|
||||
// so a firm press that drifts still counts) and asks where the finger let go instead. The lock itself stays: without
|
||||
// it a finger that slides on to a slider would press and drag that slider.
|
||||
lv_point_t point;
|
||||
if (cyd::touch_guard.move_limit() <= 0 && finger_at(point) && !lv_obj_hit_test(w.tile, &point)) {
|
||||
ESP_LOGI("touch", "tap on %s ignored: %s outside the tile", model.tiles[w.index].entity.c_str(),
|
||||
code == LV_EVENT_LONG_PRESSED ? "held" : "let go");
|
||||
return;
|
||||
}
|
||||
// The built-in cards need nothing from Home Assistant: the settings card opens the screen's own page with
|
||||
// the link down too, as its card says (firmware 0.2.49+), and the clock card does nothing under a finger.
|
||||
if (model.tiles[w.index].builtin()) {
|
||||
@@ -2137,6 +2161,22 @@ inline void event(lv_event_t *event) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The push that wakes a screen in standby only wakes it (dim_wake_overlay), with one exception (firmware 0.2.65+): on a
|
||||
// page that is one full tile which switches something on or off, the push also switches it, as a light switch on the
|
||||
// wall does in the dark. It is handed to the tile as its own tap, so it goes the way a tap on a lit screen goes (the
|
||||
// touch guard, the busy sheet, the new stand at once) and counts once. A push on the tile's slider or keys, the top bar
|
||||
// or the page bar only wakes, as does a flick. Called by both boards' dim_wake_overlay; true when it tapped the tile.
|
||||
inline bool wake_tap() {
|
||||
auto &w = widgets[0];
|
||||
if (!enabled || !fresh() || !w.tile || !w.full || w.index >= model.count || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN)) return false;
|
||||
const auto &t = model.tiles[w.index];
|
||||
const auto route = tile_controls::tap_route(t, false);
|
||||
if (t.builtin() || route.route != tile_controls::TapRoute::ACTION || route.service != t.domain() + ".toggle") return false;
|
||||
lv_point_t point;
|
||||
if (!finger_at(point) || lv_indev_search_obj(w.tile, &point) != w.tile) return false;
|
||||
lv_obj_send_event(w.tile, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
return true;
|
||||
}
|
||||
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
|
||||
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
|
||||
inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector) {
|
||||
@@ -3150,9 +3190,10 @@ inline void render_slot(size_t slot) {
|
||||
// What the next render() draws besides the name and the top bar: the cards of the tiles a state
|
||||
// message or a tick named, or every card. A refresh that names nothing (the board's own triggers,
|
||||
// such as the minute tick and time sync) draws every card.
|
||||
inline uint32_t dirty_tiles=0;
|
||||
inline uint64_t dirty_tiles=0;
|
||||
inline bool dirty_all=false, dirty_header=false;
|
||||
inline void refresh_tile(size_t index) { if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; if(refresh)refresh(); }
|
||||
inline void mark_tile(size_t index) { if(uint64_t bit=tile_bit(index))dirty_tiles|=bit; else dirty_all=true; }
|
||||
inline void refresh_tile(size_t index) { mark_tile(index); if(refresh)refresh(); }
|
||||
inline void refresh_header_only() { dirty_header=true; if(refresh)refresh(); }
|
||||
inline void refresh_all() { dirty_all=true; if(refresh)refresh(); }
|
||||
// Slots whose new page content is still to come: the page fill draws them, render() leaves them.
|
||||
@@ -3173,12 +3214,12 @@ inline void render(lv_obj_t *room) {
|
||||
render_header();
|
||||
lap(swipe_profile::HEADER);
|
||||
bool all=dirty_all || (!dirty_tiles && !dirty_header);
|
||||
uint32_t tiles=dirty_tiles;
|
||||
uint64_t tiles=dirty_tiles;
|
||||
dirty_all=dirty_header=false;dirty_tiles=0;
|
||||
for (size_t slot = 0; slot < SLOTS_PER_PAGE; ++slot) {
|
||||
const auto &w=widgets[slot];
|
||||
if(slot_pending[slot] || w.index>=model.count)continue;
|
||||
if(all || (w.index<32 && (tiles>>w.index)&1))render_slot(slot);
|
||||
if(all || (tiles & tile_bit(w.index)))render_slot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3664,7 +3705,7 @@ inline void tick() {
|
||||
if(!enabled)return;
|
||||
// Only the cards that change are drawn again: a clock or a finished command redraws its own card.
|
||||
bool redraw=false;
|
||||
auto card=[&](size_t index){ if(index<32)dirty_tiles|=1u<<index; else dirty_all=true; redraw=true; };
|
||||
auto card=[&](size_t index){ mark_tile(index); redraw=true; };
|
||||
// HA dropping or returning and the feed timing out change every card at once.
|
||||
bool now_fresh=fresh();
|
||||
if(now_fresh!=was_fresh){was_fresh=now_fresh;dirty_all=true;redraw=true;}
|
||||
@@ -3712,7 +3753,10 @@ inline void tick() {
|
||||
// The second hand moves on its own: only its line is redrawn, and it hides during standby. Only while the
|
||||
// slot's parts are a dial: right after a page switch the slot already names the clock while its parts still
|
||||
// belong to the card drawn before (a forecast's hour labels take part 18 too), until the fill draws the dial.
|
||||
else if(w.parts[18] && w.points && w.extra_mode=="analog" && t.is_clock() && t.display=="analog")second_hand(w,now);
|
||||
// A single dial is drawn as "calendar", a wide or full one as "analog"; firmware 0.2.62-0.2.64 moved only the
|
||||
// latter, so the hand of a single clock stood still (0.2.65). Part 18 must be the hand's own line either way.
|
||||
else if(w.parts[18] && lv_obj_check_type(w.parts[18],&lv_line_class) && w.points &&
|
||||
(w.extra_mode=="analog" || w.extra_mode=="calendar") && t.is_clock() && t.display=="analog")second_hand(w,now);
|
||||
}
|
||||
}
|
||||
if(redraw && refresh)refresh();
|
||||
|
||||
@@ -664,7 +664,9 @@ inline void draw() {
|
||||
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,
|
||||
// CLICKED, not SHORT_CLICKED: LVGL sends no short click after a press of long_press_time (400 ms), so a firm or
|
||||
// slow press did nothing (firmware 0.2.65+). The bar has no hold of its own.
|
||||
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_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, theme::MUTED,
|
||||
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
# The same image as the Home Assistant app, built from the main branch.
|
||||
context: https://github.com/MaxGramser/homeassistant_espscreen.git#main:screen_manager
|
||||
restart: unless-stopped
|
||||
# A small init as process 1, like the Home Assistant app gets: it passes on docker stop and reaps finished builds.
|
||||
init: true
|
||||
# Home Assistant reaches the page on 127.0.0.1:8099, and .local names resolve.
|
||||
network_mode: host
|
||||
environment:
|
||||
|
||||
+23
-21
@@ -38,9 +38,9 @@ Use the GitHub version for updates; a local test add-on is a separate app.
|
||||
1. Connect the screen with a **USB data cable** to the machine running Home
|
||||
Assistant. With multiple boards: connect them one at a time for the first
|
||||
installation, or check which port belongs to this screen.
|
||||
2. Open **Settings** at the top right of ESP Screens and click **New screen**. Choose CYD or Guition and give the screen a name,
|
||||
for example `Kitchen`. The device name (`kitchen`) follows from that; use
|
||||
**customize** to choose a different one.
|
||||
2. Click **New screen** in the sidebar of ESP Screens, under your screens. Choose CYD
|
||||
or Guition and give the screen a name, for example `Kitchen`. The device name
|
||||
(`kitchen`) follows from that; use **customize** to choose a different one.
|
||||
3. Wi-Fi: if `wifi_ssid` and `wifi_password` are already in the ESPHome `secrets.yaml`,
|
||||
the screen uses them automatically. If they're missing, or the file doesn't
|
||||
exist yet, the window asks for them once and ESP Screens only adds the
|
||||
@@ -93,7 +93,7 @@ USB, see [CALIBRATING.md](CALIBRATING.md).
|
||||
## 3. Pair the screen with Home Assistant
|
||||
|
||||
This happens in Home Assistant itself, outside ESP Screens. As long as a profile
|
||||
hasn't been added to Home Assistant yet, it appears on the left under **My screens**
|
||||
hasn't been added to Home Assistant yet, it appears in the sidebar under **Screens**
|
||||
as a *not yet in Home Assistant* card, with an **Open Devices & services**
|
||||
button and **Copy API key**; the done screen of **New screen** has the same
|
||||
button. The card disappears once the screen is in the list.
|
||||
@@ -115,8 +115,10 @@ Tip: in your ESPHome YAML, add the line `power_save_mode: none` under `wifi:` (n
|
||||
wizard YAMLs already have it); the screen is mains-powered, so this keeps it responding to
|
||||
Home Assistant without Wi-Fi sleep delay.
|
||||
|
||||
Select your screen, fill in the title, and search for entities. The picker has
|
||||
domain filters with colored icons. You can add up to twenty tiles.
|
||||
Select your screen, tap the bar at the top of a page to give the screen its name,
|
||||
and search for entities in the **Library** on the right. It has domain filters with
|
||||
colored icons. You can add up to 48 tiles, one for every slot
|
||||
(firmware 0.2.62+; see below for older firmware).
|
||||
The screen preview shows their placement: two columns, six slots per page,
|
||||
up to eight pages. Every tile has a fixed slot that only changes if
|
||||
you drag it; empty slots stay empty, wherever you leave them. Drag a tile
|
||||
@@ -125,27 +127,27 @@ swap (the other tile takes the freed-up slot, or otherwise the nearest free
|
||||
slot); everything else stays put. While dragging, the preview already shows where
|
||||
everything will land; an empty page then stands ready below the last page. **Add
|
||||
page** creates an empty page that's kept; an empty page gets
|
||||
**Remove page**. Click an empty slot to place the next tile from the picker
|
||||
**Remove page**. Click an empty slot to place the next tile from the library
|
||||
there. Use the arrow keys to move a focused tile.
|
||||
Click a tile for a custom name and **Configure control & display**:
|
||||
click behavior, a mini-slider, a large value, a graph (sensors), a
|
||||
weather forecast (weather), or the **Double-width** setting. A double-width tile for
|
||||
a climate, switch, light, fan, vacuum, cover, media player,
|
||||
number, select, timer, scene, script, or button gets **direct
|
||||
Click a tile and its settings open in a drawer on the right, with the preview
|
||||
still in view: a custom name, click behavior, a mini-slider, a large value, a graph
|
||||
(sensors), a weather forecast (weather), or the size: **Double-width** or **Full
|
||||
page** (firmware 0.2.62+). A double-width tile for a climate, switch, light, fan,
|
||||
vacuum, cover, media player, number, select, timer, scene, script, or button gets **direct
|
||||
control** on the right, like the rows in Home Assistant (for example temperature − / +,
|
||||
open/stop/close, volume with mute, a toggle); under **Direct control
|
||||
on the tile**, choose which set, or **None** (firmware 0.2.19+). **Open control** on
|
||||
a weather tile shows the weather card with the coming hours and days (rain included);
|
||||
on a climate tile, the card with an on/off button and the mode, fan, and
|
||||
swing settings. The built-in **Clock**
|
||||
sits at the top of the picker; find sun, timers, and people via the filters. Under
|
||||
sits at the top of the library; find sun, timers, and people via the filters. Under
|
||||
**Pastel background**, choose a custom color with dark text; **Default** restores
|
||||
the normal look, and **None** drops the card, so the content sits the same size
|
||||
directly on the screen background (firmware 0.2.16+). This requires firmware 0.2.10+.
|
||||
Adding without a chosen slot fills the first free slot. Fixed slots and empty
|
||||
slots work on the screen from firmware 0.2.26 on; older firmware shifts the
|
||||
tiles up to the first free slot, and the editor notes that below the preview.
|
||||
The preview shows the layout, not live sensor values.
|
||||
The preview shows the values Home Assistant reports right now (app 0.2.73+).
|
||||
Click **Save & send** to send your changes.
|
||||
|
||||
- Light, switch, input_boolean, and fan: tap to turn on/off.
|
||||
@@ -164,11 +166,11 @@ Click **Save & send** to send your changes.
|
||||
shows 1, 6, or 24 hours.
|
||||
- Select/input_select: open the picker menu.
|
||||
|
||||
From firmware 0.2.7, twenty tiles fit across up to four pages. Older
|
||||
firmware keeps the limit of ten until you update. Under **General settings
|
||||
→ Swipe between pages**, you can enable swiping. On the Guition (firmware
|
||||
0.2.24+), you then swipe inward from the left or right edge, like the
|
||||
back-swipe gesture on a phone; slow or fast, and a swipe starting in the
|
||||
From firmware 0.2.62, 48 tiles fit across up to eight pages. Firmware 0.2.7 to
|
||||
0.2.61 keeps the limit of twenty (four pages) and older firmware ten, until you
|
||||
update. In the **Screen settings** tab, **Swipe between pages** turns on swiping.
|
||||
On the Guition (firmware 0.2.24+), you then swipe inward from the left or right
|
||||
edge, like the back-swipe gesture on a phone; slow or fast, and a swipe starting in the
|
||||
middle of the screen does nothing, so tapping and dragging tiles never
|
||||
accidentally changes pages. On the CYD, it stays a quick swipe across the screen. Sliders
|
||||
only control their value; detail menus and standby don't change pages.
|
||||
@@ -263,8 +265,8 @@ HA mechanisms used: [Ingress](https://developers.home-assistant.io/docs/apps/pre
|
||||
|
||||
Update ESP Screen Manager to 0.1.2 and install the new firmware once
|
||||
via your **existing** ESPHome device → Install → Wirelessly. Keep your own YAML
|
||||
with Wi-Fi and keys. Then open the screen in ESP Screen Manager and expand
|
||||
**Screen settings**. After adjusting, click **Save & send**.
|
||||
with Wi-Fi and keys. Then open the screen in ESP Screen Manager and its
|
||||
**Screen settings** tab. A change there applies at once; there is nothing to save.
|
||||
After this, changes to these settings don't need a new firmware flash.
|
||||
|
||||
| Setting | Options | Default |
|
||||
|
||||
+11
-10
@@ -27,10 +27,10 @@ carry over `calibration.yaml` or the XPT2046 correction.
|
||||
### Mounting and screen rotation (runtime 0.2.9+)
|
||||
|
||||
Install Guition firmware 0.2.9 and ESP Screen Manager 0.2.9. Open the screen
|
||||
in the management page, go to **Screen settings**, and choose 0°, 90°, 180°,
|
||||
or 270° (clockwise) under **Rotate screen**. Click save. After that, a
|
||||
firmware flash is no longer needed to change the angle. The setting is kept
|
||||
in the app data and on the screen after a restart.
|
||||
in the management page, open the **Screen settings** tab, and choose 0°, 90°,
|
||||
180°, or 270° (clockwise) under **Rotation**. It applies at once; there is
|
||||
nothing to save. After that, a firmware flash is no longer needed to change the
|
||||
angle. The setting is kept in the app data and on the screen after a restart.
|
||||
|
||||
This uses native ESPHome/LVGL rotation for both display and touch, with no changes
|
||||
to the panel initialization or GT911 mirroring. After mounting, physically check the
|
||||
@@ -39,18 +39,19 @@ only visible for the Guition once its new firmware has been discovered by HA.
|
||||
The CYD stays on its existing orientation with its own calibration.
|
||||
|
||||
Six tiles of **218 × 108 pixels** per page, 12px spacing, and a
|
||||
separate navigation strip. In ESP Screen Manager, twenty tiles fit across up to
|
||||
four pages; navigation disappears at six or fewer. The current interface has a
|
||||
light gray background, white cards, and colored domain icons. From 0.2.10, you
|
||||
can choose a pastel background with dark text per tile. Standby starts
|
||||
after ten minutes without touch by default and is adjustable in the management page.
|
||||
separate navigation strip. In ESP Screen Manager, up to 48 tiles fit across up to
|
||||
eight pages (firmware 0.2.62+; twenty over four pages before); navigation disappears
|
||||
at six or fewer. The current interface has a light gray background, white cards,
|
||||
and colored domain icons. From 0.2.10, you can choose a pastel background with
|
||||
dark text per tile. Standby starts after ten minutes without touch by default and
|
||||
is adjustable in the management page.
|
||||
The backlight dims through ESPHome's own light transition (1.5 s to standby, 80 ms to
|
||||
wake). Firmware 0.2.13-0.2.56 used the LEDC hardware fader (`backlight_fade.h`) instead;
|
||||
0.2.57 went back to ESPHome's transition, because that fader reached into ESPHome's LEDC
|
||||
output, which ESPHome 2026.8 no longer allows.
|
||||
|
||||
The existing tile actions, climate control, and vacuum card are preserved.
|
||||
Every card type works in all twenty positions.
|
||||
Every card type works in all 48 positions.
|
||||
|
||||
## New installation
|
||||
|
||||
|
||||
+78
-9
@@ -23,16 +23,42 @@
|
||||
The editor is the Vue app in `web/`: after a change under `web/src`, run
|
||||
`cd web && npm ci && npm test && npm run check && npm run build` and commit
|
||||
`screen_manager/app/static` with it (that folder is the build output; never edit it by hand).
|
||||
2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the
|
||||
generator with `--check`, and compile both Easy Setup profiles. Do that
|
||||
sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a
|
||||
parallel build can make an upload pick the wrong `firmware.bin`. Check that no
|
||||
secrets are in Git.
|
||||
2. Run `tools/check.sh` (with `PYTHON=.venv-portal/bin/python` on a development machine). It runs all
|
||||
Python tests with aiohttp, PyYAML, Pillow and fontTools installed, every `tests/*.cpp` with
|
||||
`clang++ -std=c++17 -Wall -Wextra -Werror -I.`, both generators with `--check`, and the editor's
|
||||
`npm ci`, `npm test`, `npm run check` and `npm run build`, and fails when that fresh build differs from the
|
||||
`screen_manager/app/static` in Git (committed or staged). For a firmware change, `tools/check.sh --firmware`
|
||||
compiles both board profiles with placeholder secrets from a temporary folder (never the real `secrets.yaml`) and
|
||||
applies the flash budget below; `--all` does both. CI (`.github/workflows/ci.yml`) runs the same script on every
|
||||
push and pull request to main. Compile sequentially: profiles with the same `DEVICE_NAME` share one build folder,
|
||||
and a parallel build can make an upload pick the wrong `firmware.bin` (the check builds are called `check-cyd` and
|
||||
`check-guition` and build under `.esphome/check`, apart from the bench profiles). Check that no secrets are in Git.
|
||||
|
||||
**Flash budget of the CYD** (Max's rule, app 0.2.78). The CYD has 4 MB of flash and two update slots of
|
||||
1,835,008 bytes; the Guition's 16 MB leave it far from any limit. Measure the CYD on the build users get: the YAML
|
||||
`core.installation_yaml()` writes has the Wi-Fi fallback access point (`wifi: ap:`) and `captive_portal:`, about
|
||||
84 KB more than a profile without them. The board profiles carry both, so `tools/check.sh --firmware` measures
|
||||
that shape; it reads the slot from the build's `partitions.csv` (`app0`, `ota_0`) and the image from
|
||||
`firmware.ota.bin`, and `--baseline <bytes>` prints the delta against the last release. The check-profile tables
|
||||
in earlier test results left both out and read about 84 KB low (0.2.72: 1,453,647 bytes, 79.2 %, where the
|
||||
user-shaped build was 1,538,032 bytes, 83.8 %). Build with the add-on's pinned ESPHome (`screen_manager/Dockerfile`)
|
||||
and, when the ESPHome Device Builder ships a newer ESPHome, with that one too (`ESPHOME=<its esphome command>`),
|
||||
because users build their updates there.
|
||||
|
||||
| CYD image, share of 1,835,008 bytes | Rule |
|
||||
|---|---|
|
||||
| up to 90 % | normal |
|
||||
| 90-93 % | tight: every release states its flash delta; a delta over 8 KB needs a matching saving or Max's OK |
|
||||
| 93-95 % | only fixes ship |
|
||||
| over 95 % | never: that keeps about 90 KB for ESPHome upgrades and users' own overrides |
|
||||
3. Test app start, saving, restarting/updating with existing layouts,
|
||||
reconnecting to HA, and an ESP restart. Test a new card on real
|
||||
hardware. A good build doesn't replace physical touch acceptance.
|
||||
4. Bump the app version and firmware project version; write the CHANGELOG and concrete
|
||||
test results. Only publish compatible changes directly to main.
|
||||
test results. Only publish compatible changes directly to main. `tests/test_release_lint.py`
|
||||
(part of `tools/check.sh`) holds `config.yaml`'s version, the first CHANGELOG heading and its
|
||||
firmware against `FIRMWARE_VERSION`, keeps the CHANGELOG headings unique and newest first, and checks
|
||||
that every `fonts/...` file the packages fetch from GitHub is in the tree.
|
||||
5. Commit and push main (the only release branch). Create an immutable tag
|
||||
`screens-vX.Y.Z` from the same commit. Test the remote YAML in an empty folder:
|
||||
all components/fonts must be fetchable via GitHub.
|
||||
@@ -52,7 +78,7 @@ For backend tests on a development machine:
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv-portal
|
||||
.venv-portal/bin/pip install aiohttp PyYAML
|
||||
.venv-portal/bin/pip install aiohttp PyYAML Pillow fonttools
|
||||
.venv-portal/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -189,6 +215,34 @@ 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.78 / firmware 0.2.65
|
||||
|
||||
Repeated navigation tiles: the same `screen.page_<n>` may sit on several pages of one screen. Firmware 0.2.65 accepts
|
||||
repeats of `screen.page_*` only (`Model::set_layout`, `page_entity()`); every Home Assistant entity still appears once.
|
||||
The app sends a repeat only to 0.2.65+ (`PAGE_TILE_REPEAT_MIN_FIRMWARE` in `min_firmware`); an older screen gets
|
||||
"Install screen firmware 0.2.65 or newer first". Tile events from Claude in HA pick a copy by `slot`/`page` (move:
|
||||
`from_slot`/`from_page`); without one they act on the first copy in slot order, and the answer names its page and slot.
|
||||
|
||||
- Firmware: `dirty_tiles` is 64 bits (`tile_bit`, `mark_tile`, `static_assert(MAX_TILES <= 64)`). `set_layout` refuses a
|
||||
layout whose tile list does not fit the largest free block (`tile_room`, ESP32 only) and reports it through
|
||||
`Model::refusal`; `TileAllocator` logs and aborts on a null block instead of writing through it. The second hand
|
||||
moves on `analog` and `calendar` dials while part 18 is an `lv_line` (`lv_obj_check_type`). Guition: an
|
||||
`LV_EVENT_GESTURE` handler on `home_page` consumes the contact (no page flip; LVGL needs speed and 50 px), and
|
||||
`runtime_tiles::event` drops a tap or hold released outside the tile (`lv_obj_hit_test`); LVGL's `PRESS_LOCK` stays so
|
||||
a finger that slides on to a slider never drags it. `dim_wake_overlay` calls `runtime_tiles::wake_tap()` after
|
||||
`wake_display`: on a page that is one full tile whose tap is `<domain>.toggle`, the waking push also taps it. Page
|
||||
bars (tile pages and the settings page) react to `on_click`/`LV_EVENT_CLICKED`. `lvgl: default_font` is a Roboto font
|
||||
of each board, so ESPHome no longer builds Montserrat 14 (CYD -13.9 KB).
|
||||
- App: `Manager.save` encodes the real layout message and refuses one over 4096 bytes; `core.packed_slots` refuses tiles
|
||||
that don't fit eight pages. `discover_screens` adds `firmware_known`: the "Screen firmware" sensor when it holds a
|
||||
strict X.Y.Z, else the device registry's `sw_version` in the form `X.Y.Z (ESPHome …)`. Every screen in the inventory
|
||||
carries `firmware_known`, `tile_limit`, `full_page` and `page_tiles_repeat`; the changelog moved from the update
|
||||
summary (and the live stream) to the full inventory's top-level `changelog`. HA refusals answer 400 and an unreachable
|
||||
HA 503, both JSON `{"error"}`. `client_max_size` 128 KB. Hashed `/assets/` files get `private, max-age=31536000,
|
||||
immutable`; the page and `/api` keep `no-store`. `config.yaml` drops `init: false` (tini is PID 1 again) and the server
|
||||
uses aiohttp's `handle_signals`.
|
||||
- Editor: `npm run build` type-checks first (`vue-tsc --noEmit`), `devEngines` pins Node. Storage unchanged.
|
||||
|
||||
### Compatibility 0.2.74 / firmware 0.2.62
|
||||
|
||||
Forty-eight tiles per screen, a tile over the whole page (`size: full`) and navigation tiles (`screen.page_1` to
|
||||
@@ -196,6 +250,11 @@ Forty-eight tiles per screen, a tile over the whole page (`size: full`) and navi
|
||||
gets "Install screen firmware 0.2.62 or newer first". Older apps keep working with this firmware: they never send
|
||||
`full` or a page tile, and the firmware still packs `wide` as before.
|
||||
|
||||
Downgrade: an app older than 0.2.74 stops at startup on a stored layout with more than 20 tiles or a page tile
|
||||
(`validate_layout` raises "Choose at most 20 tiles." or "This entity isn't supported." even with `stored=True`);
|
||||
`screens.json` stays untouched. Before going back, trim the layout to 20 normal tiles, or restore the app backup (the
|
||||
Supervisor restores the version and its data together).
|
||||
|
||||
- Firmware: `MAX_TILES` is `MAX_SLOTS` (48). `Model::tiles` is a `std::vector` sized to the layout (a `TileAllocator`
|
||||
over ESPHome's `RAMAllocator`, so a Guition keeps the list in PSRAM; the CYD's falls back to its heap); nothing may
|
||||
index `tiles` beyond `count`. A `Tile` has `full` next to `wide` (a full tile is also wide) and `cells()`; `pack()`
|
||||
@@ -227,6 +286,9 @@ storage or editor change, and an older app drives this firmware as before.
|
||||
the Guition), a contact that moved, a contact that already counted, and a tap within 150 ms of the last accepted
|
||||
tap on the same button (bounce). `show_page` already drops a fill still under way when the next page is asked for,
|
||||
so nothing else changed. `tests/test_cyd_ui.cpp` covers the page buttons.
|
||||
- On the CYD a lift-off bounce is stopped first by the XPT2046 filter (`components/xpt2046/touchscreen/touch_filter.h`:
|
||||
three matching samples start a contact, two empty samples release it, one empty sample is ignored); the 40 ms
|
||||
minimum and the 150 ms same-button gap of `accept_repeat` are backup nets behind it (noted 2026-09-18).
|
||||
- The settings page's own pager (`settings_screen.h`) never used the guard and is unchanged. Tile taps, cards, keys,
|
||||
chips and sliders keep `accept()` and its window.
|
||||
|
||||
@@ -1138,8 +1200,15 @@ rotation mapping, `update()`, `end()`); the indev callback is gone. Otherwise un
|
||||
### Compatibility 0.2.33 / firmware 0.2.28
|
||||
|
||||
Firmware only. `TouchGuard::configure(0, ...)` disables the movement limit
|
||||
(`moved_` is then never set; LVGL's press-lost decides whether a tap goes through). The
|
||||
Guition profile sets `TOUCH_MOVE_LIMIT_PX` to 0, the CYD keeps 56. Otherwise unchanged.
|
||||
(`moved_` is then never set). The Guition profile sets `TOUCH_MOVE_LIMIT_PX` to 0, the
|
||||
CYD keeps 56. Otherwise unchanged.
|
||||
|
||||
Correction (2026-09-18): this note said LVGL's press-lost then decides whether a tap goes
|
||||
through. It doesn't: a tile keeps LVGL's `LV_OBJ_FLAG_PRESS_LOCK` (on by default), so a
|
||||
finger that slides off never sends `LV_EVENT_PRESS_LOST`, and on the Guition a release
|
||||
outside the tile still counted as a tap. From firmware 0.2.65 a tile without a movement limit
|
||||
checks it itself (`runtime_tiles::event`): a tap or hold whose finger lets go outside the tile
|
||||
does nothing. PRESS_LOCK stays on.
|
||||
|
||||
### Compatibility 0.2.32 / firmware 0.2.27
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
# Testresultaten app 0.2.33 / firmware 0.2.28 (2026-09-14)
|
||||
|
||||
Alleen firmware: op de Guition beslist LVGL of een tik doorgaat (loslaten binnen de tegel
|
||||
telt), de verplaatsingsgrens van de tap-guard staat daar uit. Max koos dit na een live
|
||||
geweigerde druk van 73 px op Studio 1.
|
||||
Alleen firmware: op de Guition zou LVGL beslissen of een tik doorgaat (loslaten binnen de
|
||||
tegel telt; zie de correctie hieronder), de verplaatsingsgrens van de tap-guard staat daar
|
||||
uit. Max koos dit na een live geweigerde druk van 73 px op Studio 1.
|
||||
|
||||
## Wat veranderde
|
||||
|
||||
- `TouchGuard::configure(move_limit_px, ...)` met 0 betekent geen grens: `moved_` wordt nooit
|
||||
gezet, `accept()` en `accept_repeat()` kijken alleen nog naar duur, dubbel gebruik en
|
||||
dender. LVGL's press-lost (vinger verlaat het object) bepaalt of de klik doorgaat.
|
||||
dender. LVGL's press-lost (vinger verlaat het object) zou bepalen of de klik doorgaat.
|
||||
- Guition-profiel `TOUCH_MOVE_LIMIT_PX: "0"`; CYD blijft 56 (resistief paneel springt).
|
||||
- Correctie (2026-09-18): LVGL beslist dat niet. Een tegel houdt LVGL's `LV_OBJ_FLAG_PRESS_LOCK`
|
||||
(standaard aan), dus een vinger die van de tegel schuift stuurt nooit `LV_EVENT_PRESS_LOST`, en
|
||||
loslaten buiten de tegel telde op de Guition nog steeds als tik. Vanaf firmware 0.2.65 controleert
|
||||
de tegel dat zelf (`runtime_tiles::event`): een tik of vasthouden waarbij de vinger buiten de tegel
|
||||
loslaat, doet niets. PRESS_LOCK blijft aan.
|
||||
|
||||
## Geautomatiseerd
|
||||
|
||||
@@ -24,6 +29,7 @@ geweigerde druk van 73 px op Studio 1.
|
||||
## Nog te controleren op Studio 1
|
||||
|
||||
- Een stevige, schuivende druk op een tegel voert de actie uit zolang je binnen de tegel
|
||||
loslaat; over de rand van de tegel loslaten doet niets (LVGL press-lost).
|
||||
loslaat; over de rand van de tegel loslaten doet niets (zo bedoeld; waar vanaf firmware 0.2.65,
|
||||
zie de correctie hierboven).
|
||||
- De randveeg (0.2.27) na een update zonder eerst een kaart te sluiten, en de eerste veeg
|
||||
na een paginawissel; het log meldt `randveeg niet gevuurd: …` als hij te kort was.
|
||||
|
||||
@@ -72,5 +72,8 @@ the sizes differ from the bench profiles by the length of those strings; the cod
|
||||
|
||||
- Real hardware: neither bench screen ran this firmware before the release. Max's own screens get it through Update;
|
||||
the tap that started this is the one to try there, on the Guition and on the CYD.
|
||||
- A resistive panel's lift-off bounce on the CYD is covered by the 40 ms minimum contact and the 150 ms gap, the same
|
||||
net the -/+ keys have had since 0.2.25 (physically tested then), not by a new measurement.
|
||||
- A resistive panel's lift-off bounce on the CYD, not measured again. Correction (2026-09-18, touch analysis): the
|
||||
first net for that bounce is the XPT2046 filter (`components/xpt2046/touchscreen/touch_filter.h`), which starts a
|
||||
contact only after three matching samples, ignores one empty sample and releases after two, so a bounce never
|
||||
reaches LVGL as a new tap. `accept_repeat`'s 40 ms minimum contact and 150 ms same-button gap are backup nets
|
||||
behind it, the same the -/+ keys have had since 0.2.25 (physically tested then).
|
||||
|
||||
@@ -24,7 +24,8 @@ CHANGELOG 0.2.74 and the compatibility note in docs/RELEASING.md. Built on 0.2.7
|
||||
|
||||
- Python (`.venv-portal`): 396 tests OK, including the new `tests/test_full_page_tiles.py` (sizes and footprints,
|
||||
in-order packing, free slots, validation of full and page tiles, the firmware they need, the layout sensor, the wire
|
||||
form of built-in tiles, tile events for Claude in Home Assistant, the 48 limit).
|
||||
form of built-in tiles, tile events for Claude in Home Assistant, the 48 limit). Correction: the committed 0.2.74
|
||||
runs 409 tests; 396 was counted on the branch before the rebase onto the Vue editor.
|
||||
- C++ (`c++ -std=c++17 -Wall -Wextra -pedantic`): 17/17 PASS. `tests/test_runtime_model.cpp` now covers 48 tiles, a
|
||||
list sized to the layout, `screen.page_<n>` validation and page targets, and full tiles in `pack()` and `place()`
|
||||
(a page of their own, snapping to the page start, the page count).
|
||||
@@ -77,7 +78,9 @@ the harness follows each board's demo layout). What the checks cover:
|
||||
Both bench boards were OTA-flashed with firmware 0.2.62 from the Mac (`esphome upload easy-<board>-device.yaml
|
||||
--device <ip>`), the Guition first, and report 0.2.62 in Home Assistant. Heap right after boot (HA sensors): Guition
|
||||
89 KB free / 60 KB minimum / 45 KB largest block (0.2.59: 76 / 57 / 33), CYD 116 KB / 107 KB / 59 KB (0.2.59: 106 /
|
||||
96 / 51).
|
||||
96 / 51). Correction: the after-boot figures are not the steady state. With 48 populated tiles the CYD has about
|
||||
97 KB free, a 49 KB largest block and a 78.6 KB minimum; a 13-tile steady state is about 98 KB free / 49 KB largest
|
||||
block, so compare against those, not against 116 / 59.
|
||||
|
||||
- `hw_push.py <scene> [cyd]` pushes one full-page scene straight to a board's `screen_message` action and, on the
|
||||
Guition, snapshots the LVGL render through `ui_snapshot`: the big button (lit amber, in Max's dark look a brown
|
||||
|
||||
@@ -33,8 +33,11 @@ Measured by compiling the same profile twice at the same commit (0.2.72, `9f5694
|
||||
| 4 bpp (this release) | 1 632 736 bytes | 89.0 % |
|
||||
| difference | +94 704 bytes | +5.2 points |
|
||||
|
||||
On 0.2.74 (`bb25353`, which added the 40 px full-page font and the 48-tile code) the release build is 1 655 568 bytes,
|
||||
90.2 % of the slot, static RAM 21.7 % (71 188 of 327 680 bytes); about 180 KB stay free for later rounds.
|
||||
Correction (after the release): that table was measured at 0.2.72, before 0.2.74's 40 px full-page icon font, which
|
||||
also went to 4 bpp. The released CYD build of this commit (`261fa82`) is 1 655 584 bytes, 90.2 % of the 1 835 008-byte
|
||||
slot: +107 744 bytes against 0.2.74 (1 547 840 bytes, 84.4 %), matching ESPHome's own glyph count (+107 756 bytes).
|
||||
So the release costs 108 KB, not 95 KB, and the slot is 90 % full, not 89 %. Static RAM 21.7 % (71 188 of 327 680
|
||||
bytes); about 180 KB stay free for later rounds.
|
||||
|
||||
The glyph bitmaps alone grow by 108 KB (309 icons at three sizes plus the big font, counted with freetype); the
|
||||
linker's alignment takes a little of that back. Fonts live in flash, so RAM does not move (24.1 % static in both
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Test results app 0.2.78 / firmware 0.2.65 (2026-09-18)
|
||||
|
||||
Max: "ga eens je ultra best doen om de commits van vandaag lokaal 100% te testen", then "gedegen code analyse en check op
|
||||
internet of dit best practise is", and after the report "can you address them?". This release fixes what that test round
|
||||
of 0.2.72 to 0.2.76 found, with Max's choices for the product questions: a swipe is never a tap, the waking push also
|
||||
switches a full-page switch, and the same page tile may sit on several pages. See CHANGELOG 0.2.78 and the compatibility
|
||||
note in docs/RELEASING.md.
|
||||
|
||||
## The test round of 0.2.72 to 0.2.76 (before the fixes)
|
||||
|
||||
- Python suite at every release point: 386 (2cb3479, 9f5694b), 398 (1ed42dc), 409 (bb25353, 261fa82), 0 skips, no
|
||||
order dependence over 43 runs (reverse, shuffled, `-X dev`). Only the older test_camera.Feed timing flake failed.
|
||||
- C++: 17/17 at every tag with the project's flags, ASan + UBSan, libc++ hardening, g++ 16 with `_GLIBCXX_DEBUG`, plus
|
||||
594 new runtime_model checks, 20,022 comparisons with the add-on's packing and 200,000 fuzz rounds, all clean.
|
||||
- Editor: Vitest 46/46, vue-tsc clean, and the committed bundle rebuilt byte for byte at 1ed42dc, bb25353 and 261fa82.
|
||||
- ESP32 builds of both boards, also fetched from GitHub the way users get them; the add-on image built and started.
|
||||
- Host harnesses: full page 37/37 per board, page buttons 12/12 per board (the 0.2.71 rule fails 3/12 and 4/12, as it
|
||||
should), wake/sleep, settings, cover and history on the Guition without regressions; 4 bpp icons measured (2 grey
|
||||
levels became 15 to 16).
|
||||
- Found and fixed here: the frozen second hand of a single analog clock (since 0.2.74), unsaved edits lost when clicking
|
||||
the open screen, dragging to page 2+ on a phone, Identify and Try it answering 500, a 48-tile layout too long for one
|
||||
message saving anyway, packing past slot 47, an offline screen counted as very old firmware, the 32-bit redraw mask,
|
||||
a crash instead of a refusal when memory runs out, a firm press on Previous/Next doing nothing, python as PID 1
|
||||
(every stop took 10 s), the 16 KB request limit, asterisks in What's new, docs that still said twenty tiles.
|
||||
|
||||
## Automated (this release)
|
||||
|
||||
- `tools/check.sh` (new; the same script runs in GitHub Actions), on this release rebased onto 0.2.77 (the media card):
|
||||
Python 454 tests OK, C++ 18/18 with `-Wall -Wextra -Werror`, packages and icons current, Vitest green, vue-tsc clean, build, and the committed editor bundle
|
||||
equal to a fresh build.
|
||||
- `tools/check.sh --firmware` with ESPHome 2026.6.2, on copies of both board profiles with the fallback access point and
|
||||
captive portal users get:
|
||||
|
||||
| Board | firmware.bin | of the app slot | vs 0.2.75 |
|
||||
|---|---|---|---|
|
||||
| CYD | 1,652,384 B | 90.0 % of 1,835,008 B (183 KB free) | -3,200 B (Montserrat 14 no longer built: -13,936 B; the 0.2.77 media card adds the rest) |
|
||||
| Guition | 2,026,608 B | 24.9 % of 8,126,464 B | |
|
||||
|
||||
The CYD sits at the edge of the new budget's "tight" band (90-93 %): the next release states its flash delta.
|
||||
|
||||
## Firmware on the Mac (host builds, virtual finger)
|
||||
|
||||
- Full-page harness (`fullpage_host.py`, 37 checks: taps, holds, drags, keys, page jumps, 48 tiles, six forecast/clock
|
||||
flips), on the fixes before the rebase onto 0.2.77: Guition 37/37, CYD 37/37. The CYD's first run gave 36/37: the
|
||||
harness repeats a tap when the host loop stretches the contact past 350 ms, and the firmware had already counted the
|
||||
first one (two light.toggle calls 0.7 s apart); the rerun without that stall passed 37/37.
|
||||
|
||||
## Not tested
|
||||
|
||||
- On real hardware: nothing of this release has run on a bench screen yet. To check on the glass: a single analog clock
|
||||
ticks; a flick across a full-page light on the Guition does nothing while a tap and a hold still work; one push on a
|
||||
dimmed screen with a full-page light switches it; a firm press on Next turns the page.
|
||||
- Unit tests cover the repeated page tiles (tests/test_runtime_model.cpp and the Python suite), the memory refusal (a
|
||||
stand-in for the free block size on the host), a consumed contact after a flick and the 300 ms page-bar case. The
|
||||
gesture handler, the slide-off check and the waking push run inside LVGL and have code review only, no host harness
|
||||
run with a finger.
|
||||
- Build machine note: Homebrew's Python 3.14.7 cannot load pyexpat on macOS 26.2 (system libexpat lacks
|
||||
`XML_SetAllocTrackerActivationThreshold`), which breaks PlatformIO's penv; the builds here ran with ESPHome 2026.6.2 in a
|
||||
Python 3.11 venv.
|
||||
+28
-5
@@ -2,9 +2,11 @@
|
||||
# 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.64"
|
||||
# 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.
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.65"
|
||||
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
|
||||
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
|
||||
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
|
||||
# The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
TOUCH_MIN_PRESS_MS: "20"
|
||||
@@ -222,6 +224,19 @@ esphome:
|
||||
lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
};
|
||||
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
|
||||
// A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that
|
||||
// bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is
|
||||
// consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL
|
||||
// wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the
|
||||
// settings page keep their drags; in standby the push still wakes the screen, it only switches nothing.
|
||||
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) {
|
||||
if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() ||
|
||||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return;
|
||||
cyd::touch_guard.consume();
|
||||
ESP_LOGI("touch", "flick: no tap");
|
||||
if (id(display_dimmed)) return;
|
||||
if (auto *input = lv_indev_active()) lv_indev_wait_release(input);
|
||||
}, LV_EVENT_GESTURE, nullptr);
|
||||
// Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
|
||||
// set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
|
||||
runtime_tiles::camera_full.load = [](const std::string &url) {
|
||||
@@ -1627,6 +1642,9 @@ display:
|
||||
|
||||
lvgl:
|
||||
id: screen_lvgl
|
||||
# The theme's font for text without a font of its own. Every label names its font, so this only keeps
|
||||
# ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+).
|
||||
default_font: sublabel
|
||||
on_draw_start:
|
||||
- lambda: 'id(rendered_frames)++;'
|
||||
displays:
|
||||
@@ -2478,7 +2496,9 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
# on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing
|
||||
# (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe.
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);'
|
||||
@@ -2519,7 +2539,7 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);'
|
||||
@@ -3693,6 +3713,9 @@ lvgl:
|
||||
clickable: true
|
||||
on_click:
|
||||
- script.execute: wake_display
|
||||
# A page that is one full tile switching something on or off switches with this push as well
|
||||
# (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes.
|
||||
- lambda: 'runtime_tiles::wake_tap();'
|
||||
|
||||
- id: touch_cal_page
|
||||
styles: paint_page_soft
|
||||
|
||||
+11
-3
@@ -17,7 +17,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.65"
|
||||
# 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.
|
||||
@@ -1634,6 +1634,9 @@ display:
|
||||
height: 320
|
||||
|
||||
lvgl:
|
||||
# The theme's font for text without a font of its own. Every label names its font, so this only keeps
|
||||
# ESPHome from building LVGL's Montserrat 14 (about 14 KB of flash) for nothing (firmware 0.2.65+).
|
||||
default_font: sublabel_big
|
||||
on_draw_start:
|
||||
- lambda: 'id(rendered_frames)++;'
|
||||
displays:
|
||||
@@ -2391,7 +2394,9 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
# on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing
|
||||
# (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe.
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);'
|
||||
@@ -2432,7 +2437,7 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);'
|
||||
@@ -3591,6 +3596,9 @@ lvgl:
|
||||
clickable: true
|
||||
on_click:
|
||||
- script.execute: wake_display
|
||||
# A page that is one full tile switching something on or off switches with this push as well
|
||||
# (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes.
|
||||
- lambda: 'runtime_tiles::wake_tap();'
|
||||
|
||||
- id: touch_cal_page
|
||||
styles: paint_calibration
|
||||
|
||||
+11
-3
@@ -18,7 +18,7 @@
|
||||
############################################################
|
||||
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.64"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.65"
|
||||
# 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.
|
||||
@@ -1623,6 +1623,9 @@ display:
|
||||
height: 320
|
||||
|
||||
lvgl:
|
||||
# The theme's font for text without a font of its own. Every label names its font, so this only keeps
|
||||
# ESPHome from building LVGL's Montserrat 14 (about 14 KB of flash) for nothing (firmware 0.2.65+).
|
||||
default_font: sublabel_big
|
||||
on_draw_start:
|
||||
- lambda: 'id(rendered_frames)++;'
|
||||
displays:
|
||||
@@ -2380,7 +2383,9 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
# on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing
|
||||
# (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe.
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);'
|
||||
@@ -2421,7 +2426,7 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);'
|
||||
@@ -3580,6 +3585,9 @@ lvgl:
|
||||
clickable: true
|
||||
on_click:
|
||||
- script.execute: wake_display
|
||||
# A page that is one full tile switching something on or off switches with this push as well
|
||||
# (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes.
|
||||
- lambda: 'runtime_tiles::wake_tap();'
|
||||
|
||||
- id: touch_cal_page
|
||||
styles: paint_calibration
|
||||
|
||||
+28
-5
@@ -3,9 +3,11 @@
|
||||
# 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.64"
|
||||
# 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.
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.65"
|
||||
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
|
||||
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
|
||||
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
|
||||
# The CYD keeps a limit because its resistive panel jumps.
|
||||
TOUCH_MOVE_LIMIT_PX: "0"
|
||||
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
|
||||
TOUCH_MIN_PRESS_MS: "20"
|
||||
@@ -226,6 +228,19 @@ esphome:
|
||||
lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN);
|
||||
};
|
||||
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
|
||||
// A quick flick or wipe over the tiles is never a tap (firmware 0.2.65+). LVGL reports it as a gesture that
|
||||
// bubbles up to this page (a tile's slider keeps its own, runtime_tiles::bind): the tap under the finger is
|
||||
// consumed and LVGL waits for the release. No page flip: pages change with the edge swipe (on_update). LVGL
|
||||
// wants speed as well as 50 px of travel, so a firm press that drifts slowly is still a tap. Cards and the
|
||||
// settings page keep their drags; in standby the push still wakes the screen, it only switches nothing.
|
||||
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *) {
|
||||
if (!runtime_tiles::enabled || id(calibration_active) || !id(active_entity).empty() || settings_screen::visible() ||
|
||||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN))) return;
|
||||
cyd::touch_guard.consume();
|
||||
ESP_LOGI("touch", "flick: no tap");
|
||||
if (id(display_dimmed)) return;
|
||||
if (auto *input = lv_indev_active()) lv_indev_wait_release(input);
|
||||
}, LV_EVENT_GESTURE, nullptr);
|
||||
// Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
|
||||
// set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
|
||||
runtime_tiles::camera_full.load = [](const std::string &url) {
|
||||
@@ -1616,6 +1631,9 @@ display:
|
||||
|
||||
lvgl:
|
||||
id: screen_lvgl
|
||||
# The theme's font for text without a font of its own. Every label names its font, so this only keeps
|
||||
# ESPHome from building LVGL's Montserrat 14 for nothing (firmware 0.2.65+).
|
||||
default_font: sublabel
|
||||
on_draw_start:
|
||||
- lambda: 'id(rendered_frames)++;'
|
||||
displays:
|
||||
@@ -2467,7 +2485,9 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
# on_click: LVGL sends no short click after a press of 400 ms, so a firm or slow press did nothing
|
||||
# (firmware 0.2.65+). The bar has no hold of its own; accept_repeat still drops bounce and a swipe.
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 11);'
|
||||
@@ -2508,7 +2528,7 @@ lvgl:
|
||||
pressed:
|
||||
styles: paint_page_pressed
|
||||
bg_opa: COVER
|
||||
on_short_click:
|
||||
on_click:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return cyd::touch_guard.accept_repeat(millis(), 12);'
|
||||
@@ -3682,6 +3702,9 @@ lvgl:
|
||||
clickable: true
|
||||
on_click:
|
||||
- script.execute: wake_display
|
||||
# A page that is one full tile switching something on or off switches with this push as well
|
||||
# (firmware 0.2.65+, runtime_tiles::wake_tap); on every other page the first touch only wakes.
|
||||
- lambda: 'runtime_tiles::wake_tap();'
|
||||
|
||||
- id: touch_cal_page
|
||||
styles: paint_page_soft
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
## 0.2.78 (firmware 0.2.65)
|
||||
|
||||
Fixes from a full test round of 0.2.72 to 0.2.76, a light switch that works in the dark, and a way back to the menu.
|
||||
|
||||
- **The analog clock on a single tile ticks again.** Since 0.2.74 its second hand stood still; only the wide and full-page clocks moved.
|
||||
- **A full-page light switch works with one push in standby.** On a dimmed screen the first touch only woke it, so the switch by the door needed two pushes most of the day. On a page that is one full tile switching something on or off, the waking push now switches it too; every other page still only wakes.
|
||||
- **A swipe is never a tap (Guition).** A quick flick or wipe across a tile, a full-page light above all, switched it. A swipe now only swipes, and a finger that slides off a tile before letting go no longer taps it, as 0.2.33 meant. A firm press that drifts a little still counts.
|
||||
- **Previous and Next react to a firm or slow press.** A press longer than about half a second did nothing; the pages of the screen's settings page too.
|
||||
- **The same Go to page tile can sit on several pages**, such as a way back to page 1 on every sub-page. *Goes to page* lists the pages the screen has plus the next one, marked *(empty)*. Claude in Home Assistant names each copy by its page or spot. Needs firmware 0.2.65.
|
||||
- **Save refuses what could never reach the screen**, with a sentence that says why: tiles whose entity IDs together are too long for one message, or tiles that don't fit on eight pages. Before, such a layout saved and then kept failing to send. Big layouts with actions on their taps save again (the request limit went from 16 to 128 KB).
|
||||
- **A screen that is offline or restarting keeps its 48 tiles** in the editor and can still be saved: ESP Screens uses the firmware version Home Assistant remembers for the device instead of treating it as a very old screen.
|
||||
- **Editor:** clicking the open screen in the sidebar (the way back from Firmware & USB, Alerts or Settings) no longer throws away unsaved changes, and a change made while *Save & send* is on its way stays unsaved. Every tile's settings have a *Page* row to move it without dragging, and on a phone the pages scroll sideways and a dragged tile reaches page 2 and beyond. When Home Assistant refuses *Identify* or *Try it*, the editor says so instead of "That didn't work". *What's new* no longer shows asterisks. The editor opens faster: the browser keeps its scripts and fonts.
|
||||
- **The app stops in a moment** instead of ten seconds, and a firmware build that is running stops cleanly. An unreadable changelog no longer stops the app from starting.
|
||||
- **The screen stays up when memory runs out:** a layout the screen has no room for is refused with a message instead of crashing it. Tiles 33 to 48 no longer redraw the whole page on every change. The CYD firmware is about 14 KB smaller (LVGL's unused Montserrat font is no longer built in).
|
||||
- Every push now runs the release checks on GitHub (`tools/check.sh`), including a compile of both firmwares and a flash budget for the CYD. The guides match the current editor (48 tiles, the sidebar), and the 0.2.75 notes now give the real cost of the smoother icons: 108 KB, 90 % of the CYD's update slot.
|
||||
- Needs firmware 0.2.65: press **Update** on the screen. Includes everything from 0.2.77.
|
||||
|
||||
## 0.2.77 (firmware 0.2.64)
|
||||
|
||||
A media card like a phone's "now playing", with the album cover on a Guition.
|
||||
@@ -22,7 +39,7 @@ The CYD in the editor shows the whole tile again.
|
||||
Smooth icons on the CYD: the power ring is a ring again.
|
||||
|
||||
- **Every icon is drawn with sixteen shades instead of two.** The icon fonts of both boards were rendered at one bit per pixel, the default ESPHome falls back to when a font does not say otherwise, while the text fonts already had four. On the CYD, at 18 and 28 pixels, that made a thin stroke such as the ring of the power icon lumpy and uneven; the edges of every other icon were ragged too. The icons now get the same four bits per pixel as the text, on the Guition as well, so both brands look alike.
|
||||
- **Costs the CYD 95 KB of flash** (89 % of its update slot is in use now, was 84 %) and no memory: fonts live in flash.
|
||||
- **Costs the CYD 108 KB of flash** (90 % of its update slot is in use now, was 84 %) and no memory: fonts live in flash. The first count, 95 KB, was taken before 0.2.74 added the big icon font of the full-page tile.
|
||||
- Needs firmware 0.2.63: press **Update** on the screen. Includes everything from 0.2.74.
|
||||
|
||||
## 0.2.74 (firmware 0.2.62)
|
||||
@@ -34,6 +51,7 @@ Forty-eight tiles, a tile over the whole page, and tiles that go to a page.
|
||||
- **Go to page.** A new built-in tile, *Go to page*, opens the page you choose: an arrow (or an icon of your own), its name and the page number, with a chevron like the rows in Home Assistant's settings. Put a full-page light switch on page 1 and a menu of *Heating*, *Blinds* and *Vacuum* on page 2, each with its own page.
|
||||
- Claude in Home Assistant can do the same: `size: full` on a tile, and `screen.page` with `to_page` for a navigation tile. The screen's sensor reports both.
|
||||
- Needs firmware 0.2.62: press **Update** on the screen. Includes everything from 0.2.73.
|
||||
- Going back to an app older than 0.2.74 after using more than 20 tiles or a *Go to page* tile: that app stops at startup ("Choose at most 20 tiles." or "This entity isn't supported."), and the saved layouts (`screens.json`) stay untouched. Before going back, trim the layout to 20 normal tiles, or restore the app's backup (the Supervisor restores the version and its data together).
|
||||
|
||||
## 0.2.73 (firmware 0.2.61)
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ build and flash run in the same window; it then shows the API key
|
||||
for pairing. Is the Home Assistant machine out of reach of the screen, such as a server or a
|
||||
virtual machine? Choose **Download** under **Install via**: ESP Screens builds the firmware and
|
||||
you put it on the screen from your own computer with [ESPHome Web](https://web.esphome.io) in
|
||||
Chrome or Edge. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Firmware & USB** in the sidebar is
|
||||
Chrome or Edge. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under Screens in the sidebar). Each screen has its own profile. **Firmware & USB** in the sidebar is
|
||||
for existing profiles: check, build or reinstall via USB, the IP address (OTA) or
|
||||
Download. Existing ESPHome profiles in the HA config folder are
|
||||
found automatically.
|
||||
|
||||
**Settings** in the sidebar has the nightly firmware updates (the Update badge of a screen lists what's new, a running update shows its progress), the **Alerts** cheatsheet with a form to try an alert
|
||||
(an alert on one screen, or on every screen with the `esp_screens_show_alert` event)
|
||||
and **Claude**: install the ESP Screens skill for Claude Code in Home Assistant, or
|
||||
**Alerts** in the sidebar is the cheatsheet, with a form to try an alert
|
||||
(an alert on one screen, or on every screen with the `esp_screens_show_alert` event).
|
||||
**Settings** in the sidebar has the nightly firmware updates (the Update badge of a screen lists what's new, a
|
||||
running update shows its progress) and **Claude**: install the ESP Screens skill for Claude Code in Home Assistant, or
|
||||
download it for claude.ai, and ask Claude for the alert automation.
|
||||
|
||||
Tap the bar at the top of any page in the mockup to set the **Top bar** of a screen: the name on the left, up
|
||||
|
||||
@@ -19,6 +19,8 @@ import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from core import screen_firmware
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
DOMAINS = ('camera', 'image')
|
||||
@@ -55,17 +57,9 @@ def supported(entity):
|
||||
and entity.split('.')[0] in DOMAINS)
|
||||
|
||||
|
||||
def version(text):
|
||||
try:
|
||||
parts = tuple(int(part) for part in str(text or '').split('.'))
|
||||
except ValueError:
|
||||
return ()
|
||||
return parts if len(parts) == 3 else ()
|
||||
|
||||
|
||||
def can_show(screen):
|
||||
"""A paired Guition with firmware that draws camera images."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
|
||||
"""A paired Guition with firmware that draws camera images (the version feature gates go by, core.screen_firmware)."""
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_supported(entity):
|
||||
@@ -76,7 +70,7 @@ def cover_supported(entity):
|
||||
|
||||
def can_show_cover(screen):
|
||||
"""A paired Guition with firmware that draws the media card's cover."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= COVER_MIN_FIRMWARE
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= COVER_MIN_FIRMWARE
|
||||
|
||||
|
||||
def cover_request(request):
|
||||
|
||||
@@ -3,39 +3,73 @@
|
||||
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
|
||||
Each `## <app> (firmware <fw>)` section gives its bullet lines as plain text, newest first.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
|
||||
HEADER = re.compile(r'^## (\d+\.\d+\.\d+) \(firmware (\d+\.\d+\.\d+)\)')
|
||||
# A heading of level one or two ends a section; a `### ` inside one doesn't.
|
||||
SECTION_END = re.compile(r'^#{1,2}(\s|$)')
|
||||
BULLET = re.compile(r'^[-*+]\s+(.*)$')
|
||||
BOLD = re.compile(r'\*\*(.+?)\*\*')
|
||||
# *Full page*: single asterisks around text. A lone asterisk (5 * 3) or one inside a word stays (app 0.2.78).
|
||||
ITALIC = re.compile(r'(?<![\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])')
|
||||
CODE = re.compile(r'`([^`]*)`')
|
||||
LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)')
|
||||
|
||||
|
||||
def load(path=None, limit=20):
|
||||
"""[{app, firmware, lines}] for the latest sections; [] without a changelog."""
|
||||
"""[{app, firmware, lines}] for the latest sections; [] without a changelog, or with one that can't be read (a
|
||||
folder, no permission, not UTF-8): the Update badge then has no notes, and the app still starts (app 0.2.78)."""
|
||||
candidates = [Path(path)] if path else [Path(__file__).with_name('CHANGELOG.md'), Path(__file__).parent.parent / 'CHANGELOG.md']
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return parse(candidate.read_text(), limit)
|
||||
try:
|
||||
return parse(candidate.read_text(encoding='utf-8'), limit)
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
LOG.warning("What's new stays empty: %s can't be read (%s)", candidate, type(error).__name__)
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def parse(text, limit=20):
|
||||
sections = []
|
||||
"""The sections of a changelog. A bullet (`- ` or `* `) takes the lines that continue it, indented or wrapped
|
||||
right under it; bullets under any other heading of level one or two belong to no section (app 0.2.78)."""
|
||||
sections, current, item, blank = [], None, None, False
|
||||
|
||||
def close():
|
||||
if item is not None and current is not None:
|
||||
current['lines'].append(plain(' '.join(item)))
|
||||
|
||||
for line in text.splitlines():
|
||||
match = HEADER.match(line)
|
||||
if match:
|
||||
if len(sections) >= limit:
|
||||
break
|
||||
sections.append({'app': match.group(1), 'firmware': match.group(2), 'lines': []})
|
||||
elif sections and line.startswith('- '):
|
||||
sections[-1]['lines'].append(plain(line[2:]))
|
||||
stripped = line.strip()
|
||||
if SECTION_END.match(line):
|
||||
close()
|
||||
item, match = None, HEADER.match(line)
|
||||
if match and len(sections) >= limit:
|
||||
return sections
|
||||
current = {'app': match.group(1), 'firmware': match.group(2), 'lines': []} if match else None
|
||||
if current:
|
||||
sections.append(current)
|
||||
elif (bullet := BULLET.match(line)) and current is not None:
|
||||
close()
|
||||
item = [bullet.group(1).strip()]
|
||||
elif stripped and item is not None and (line[:1].isspace() or not blank) and not line.startswith('#'):
|
||||
# Markdown continues a bullet with an indented line, or with any line right under it.
|
||||
item.append(stripped)
|
||||
elif stripped:
|
||||
close()
|
||||
item = None
|
||||
blank = not stripped
|
||||
close()
|
||||
return sections
|
||||
|
||||
|
||||
def plain(markdown):
|
||||
text = BOLD.sub(r'\1', markdown)
|
||||
text = CODE.sub(r'\1', text)
|
||||
text = LINK.sub(r'\1', text)
|
||||
return text.strip()
|
||||
"""The text of one bullet without Markdown: bold, italics and links outside code, and the code's backticks."""
|
||||
parts = CODE.split(markdown)
|
||||
for index in range(0, len(parts), 2):
|
||||
parts[index] = LINK.sub(r'\1', ITALIC.sub(r'\1', BOLD.sub(r'\1', parts[index])))
|
||||
return ''.join(parts).strip()
|
||||
|
||||
@@ -14,11 +14,13 @@ import zipfile
|
||||
import tile_icons
|
||||
from core import (ALERT_CAMERA_FIELD, 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, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_TILES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
|
||||
DARK_MODE_MIN_FIRMWARE)
|
||||
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
|
||||
SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE,
|
||||
SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE)
|
||||
# Full-page tiles, navigation tiles and forty-eight tiles per screen.
|
||||
FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE)
|
||||
# The same navigation tile on several pages (app 0.2.78).
|
||||
PAGE_TILE_REPEAT_VERSION = '.'.join(str(part) for part in PAGE_TILE_REPEAT_MIN_FIRMWARE)
|
||||
|
||||
NAME = 'esp-screens'
|
||||
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
|
||||
@@ -92,6 +94,8 @@ actions:
|
||||
| `name` | A name of your own on the tile; leave it out to keep Home Assistant's. |
|
||||
| `page` | Page, counted from 1. Without a spot the tile takes the first free one on that page. |
|
||||
| `row`, `column` | An exact spot on that page: row 1 to {SLOTS_PER_PAGE // 2}, column `left` or `right`. |
|
||||
| `slot` | An exact spot as the screen's sensor counts it, 0 to {MAX_SLOTS - 1}: instead of `page`, `row` and `column`. |
|
||||
| `from_page`, `from_slot` | Only for `esp_screens_move_tile`: which copy of a navigation tile that is on several pages to move (see below). |
|
||||
| `size` | `single`, `wide` or `full` (the whole page; firmware {FULL_PAGE_VERSION} or newer). |
|
||||
| `controls` | What you can operate on the tile itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). |
|
||||
@@ -102,7 +106,11 @@ actions:
|
||||
|
||||
A tile with a control, a forecast or a sun path is drawn double-width on its own; you don't have to ask for that.
|
||||
|
||||
A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. One such tile per page it goes to; it is single or double width, never full-page.
|
||||
A navigation tile goes to another page: add the entity `screen.page_3` (for page 3, `screen.page_1` to `screen.page_{MAX_PAGES}`) with a `name` such as "Heating"; it shows an arrow (or an `icon`), its name and the page number, and a tap opens that page. Handy as a menu on page 2 when page 1 is one full-page light switch. It is single or double width, never full-page.
|
||||
|
||||
On firmware {PAGE_TILE_REPEAT_VERSION} or newer the same navigation tile can be on several pages, such as a `screen.page_1` named "Back" on every other page: add it with the `page` (or spot) it goes on, and a page without a copy gets a new one. Older firmware has one per page it goes to, and adding it again moves that one. Every other entity is on a screen once.
|
||||
|
||||
When a navigation tile is on several pages, say which copy you mean: `page` or `slot` for `esp_screens_add_tile` (change that copy) and `esp_screens_remove_tile`, `from_page` or `from_slot` for `esp_screens_move_tile` (its `page` and `slot` say where to), and in `esp_screens_order_tiles` each time you name it takes the next copy. An event that doesn't say acts on the first copy, the one with the lowest `slot` in the sensor.
|
||||
|
||||
### What a tile can do, per kind of entity
|
||||
|
||||
@@ -118,7 +126,7 @@ Everything else shows its name and state, and opens a card of its own on a long
|
||||
|
||||
### Reading a screen first
|
||||
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile). Read that before moving things around, so you know what is already there and where.
|
||||
Every screen also publishes what it shows, as `sensor.esp_screens_<device name>`: the state is the number of tiles, and the attributes hold `title`, `pages` and `tiles` with `entity`, `name`, `page`, `row`, `column`, `slot`, `size`, `controls` and `display` per tile (and `to_page` for a `screen.page_<n>` tile), every copy of a navigation tile on its own. Read that before moving things around, so you know what is already there and where.
|
||||
|
||||
Ordering a page means naming the tiles that are on it, in the order you want:
|
||||
|
||||
@@ -135,7 +143,7 @@ A tile from another page has to be moved there first (`esp_screens_move_tile` wi
|
||||
|
||||
### What comes back
|
||||
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity` and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event.
|
||||
ESP Screens answers every event with `{TILE_RESULT_EVENT}`, carrying `ok`, the `screen`, the `entity`, the `page` and `slot` of the tile it placed, changed, moved or removed (not for an order) and, when it refused, an `error` that says why ("No screen called ...", "That spot is taken by ...", "This screen already has {MAX_TILES} tiles", "No page is empty; a full-page tile needs a page of its own"). The add-on log says the same. Nothing changes on a refused event.
|
||||
|
||||
### How to work
|
||||
|
||||
|
||||
+166
-33
@@ -12,8 +12,10 @@ 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 camera image'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings', **{f'screen.page_{n}': f'Go to page {n}' for n in range(1, 9)}}
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n; one per page it goes to, so an entity still appears once.
|
||||
# A navigation tile (firmware 0.2.62+): screen.page_<n> goes to page n. Firmware 0.2.65+ takes the same one on several
|
||||
# pages (a "Back to page 1" on every page); every other entity still appears once on a screen.
|
||||
PAGE_TILE = 'screen.page_'
|
||||
PAGE_TILE_REPEAT_MIN_FIRMWARE = (0, 2, 65)
|
||||
|
||||
def page_target(entity):
|
||||
"""The page a navigation tile opens, counted from one; 0 for any other entity."""
|
||||
@@ -28,11 +30,14 @@ COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
MAX_TILES = 48
|
||||
LEGACY_MAX_TILES = 20
|
||||
FULL_PAGE_MIN_FIRMWARE = (0, 2, 62)
|
||||
# Twenty tiles from firmware 0.2.7, ten before.
|
||||
TWENTY_TILES_MIN_FIRMWARE = (0, 2, 7)
|
||||
FIRST_MAX_TILES = 10
|
||||
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.64'
|
||||
FIRMWARE_VERSION = '0.2.65'
|
||||
# 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.
|
||||
@@ -115,6 +120,15 @@ def pack_slots(tiles):
|
||||
position += cells_of(size)
|
||||
return slots
|
||||
|
||||
def packed_slots(tiles):
|
||||
"""pack_slots for tiles that must fit on the screen: ValueError when the packing runs past the last page (48
|
||||
tiles with one of them double-width, or 43 with one full-page tile), before a save or an event stores it (app
|
||||
0.2.78; the save used to fail later with "Invalid tile position")."""
|
||||
slots = pack_slots(tiles)
|
||||
if any(footprint(slot, tile_size(tile))[-1] >= MAX_SLOTS for tile, slot in zip(tiles, slots)):
|
||||
raise ValueError("These tiles don't fit on eight pages; remove a tile or make one smaller.")
|
||||
return slots
|
||||
|
||||
def has_gaps(tiles):
|
||||
"""True when the stored positions differ from the in-order packing, so firmware
|
||||
before 0.2.26 (which ignores `slots`) would show another arrangement."""
|
||||
@@ -168,6 +182,50 @@ NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
|
||||
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
|
||||
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
|
||||
|
||||
def parse_firmware(text):
|
||||
"""(major, minor, patch) of a screen firmware version such as "0.2.63"; None for anything else. Strict on purpose:
|
||||
the firmware reports exactly its project version, and anything with a suffix is not a release."""
|
||||
match = re.fullmatch(r'([0-9]+)\.([0-9]+)\.([0-9]+)', text) if isinstance(text, str) else None
|
||||
return tuple(int(part) for part in match.groups()) if match else None
|
||||
|
||||
# Home Assistant's device registry keeps an ESPHome device's version while the device is offline or restarting. With
|
||||
# `esphome: project:` (both board profiles) the ESPHome integration writes "<project version> (ESPHome <version>)".
|
||||
REGISTRY_FIRMWARE = re.compile(r'^([0-9]+\.[0-9]+\.[0-9]+) \(ESPHome ')
|
||||
|
||||
def registry_firmware(sw_version):
|
||||
"""The screen firmware in a device registry sw_version, or None. Firmware built without a project has only
|
||||
ESPHome's own version there, which must never read as a screen firmware."""
|
||||
match = REGISTRY_FIRMWARE.match(sw_version) if isinstance(sw_version, str) else None
|
||||
return match.group(1) if match else None
|
||||
|
||||
def known_firmware(sensor, sw_version):
|
||||
"""The version text discover_screens gives a screen as `firmware_known`: the "Screen firmware" sensor when it holds
|
||||
one, else the device registry's; None when neither does."""
|
||||
return sensor if parse_firmware(sensor) else registry_firmware(sw_version)
|
||||
|
||||
def screen_firmware(screen):
|
||||
"""The firmware a screen's feature gates go by (app 0.2.78): its "Screen firmware" sensor, or while that has no
|
||||
version (the screen is offline or restarting) the one Home Assistant's device registry kept; None when neither
|
||||
says. Before, an offline screen counted as firmware 0.0.0 and a save of more than ten tiles was refused."""
|
||||
screen = screen or {}
|
||||
return parse_firmware(screen.get('firmware_known')) or parse_firmware(screen.get('firmware'))
|
||||
|
||||
def version_text(version):
|
||||
""""0.2.65" for (0, 2, 65); None for None."""
|
||||
return '.'.join(str(part) for part in version) if version else None
|
||||
|
||||
def tile_limit(version):
|
||||
"""How many tiles firmware `version` (a tuple, or None when unknown) takes."""
|
||||
version = version or (0, 0, 0)
|
||||
return MAX_TILES if version >= FULL_PAGE_MIN_FIRMWARE else LEGACY_MAX_TILES if version >= TWENTY_TILES_MIN_FIRMWARE else FIRST_MAX_TILES
|
||||
|
||||
def firmware_features(version):
|
||||
"""What the editor may offer a screen with firmware `version` (a tuple, or None): the tile limit, full-page and
|
||||
navigation tiles, and the same navigation tile on several pages."""
|
||||
version = version or (0, 0, 0)
|
||||
return {'tile_limit': tile_limit(version), 'full_page': version >= FULL_PAGE_MIN_FIRMWARE,
|
||||
'page_tiles_repeat': version >= PAGE_TILE_REPEAT_MIN_FIRMWARE}
|
||||
|
||||
def entity_slug(name):
|
||||
"""The end of an entity id Home Assistant derives from an entity name (ASCII names)."""
|
||||
return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
|
||||
@@ -386,8 +444,15 @@ def validate_header(data):
|
||||
items.append(clean)
|
||||
return {'items': items}
|
||||
|
||||
def repeated_page_tiles(tiles):
|
||||
"""True when a navigation tile to the same page is on the screen more than once (firmware 0.2.65+)."""
|
||||
pages = [tile['entity'] for tile in tiles if page_target(tile['entity'])]
|
||||
return len(pages) != len(set(pages))
|
||||
|
||||
def min_firmware(layout):
|
||||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||||
if repeated_page_tiles(layout['tiles']):
|
||||
return PAGE_TILE_REPEAT_MIN_FIRMWARE
|
||||
if len(layout['tiles']) > LEGACY_MAX_TILES or any(is_full(t) or page_target(t['entity']) for t in layout['tiles']):
|
||||
return FULL_PAGE_MIN_FIRMWARE
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']):
|
||||
@@ -398,8 +463,8 @@ def min_firmware(layout):
|
||||
return (0, 2, 16)
|
||||
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
|
||||
return (0, 2, 14)
|
||||
if len(layout['tiles']) > 10:
|
||||
return (0, 2, 7)
|
||||
if len(layout['tiles']) > FIRST_MAX_TILES:
|
||||
return TWENTY_TILES_MIN_FIRMWARE
|
||||
return None
|
||||
|
||||
def short(value, limit):
|
||||
@@ -543,6 +608,29 @@ def event_slot(data, page):
|
||||
def page_of(tile):
|
||||
return tile.get('slot', 0) // SLOTS_PER_PAGE
|
||||
|
||||
def copies_of(tiles, entity):
|
||||
"""The tiles of one entity in slot order: one, or several copies of a navigation tile (firmware 0.2.65+)."""
|
||||
return sorted((tile for tile in tiles if tile['entity'] == entity), key=lambda tile: tile.get('slot', 0))
|
||||
|
||||
def pick_copy(found, page=None, slot=None):
|
||||
"""The copy an event means: the one covering `slot`, else the first on `page`, else the first; None when none is
|
||||
there. Only a navigation tile has several; an event that doesn't say which acts on the first (app 0.2.78)."""
|
||||
if slot is not None:
|
||||
return next((tile for tile in found if 'slot' in tile and slot in footprint(tile['slot'], tile_size(tile))), None)
|
||||
if page is not None:
|
||||
return next((tile for tile in found if page_of(tile) == page), None)
|
||||
return found[0] if found else None
|
||||
|
||||
def event_source(data):
|
||||
"""(page, slot) of the copy a move event means: `from_page` counted from one, or `from_slot`; None when not named."""
|
||||
page = event_page({'page': data.get('from_page')})
|
||||
slot = event_slot({'slot': data.get('from_slot')}, None)
|
||||
return (slot // SLOTS_PER_PAGE if slot is not None else page), slot
|
||||
|
||||
def where(page, slot):
|
||||
"""The place an event named, as its answer says it: "spot 12" or "page 3"."""
|
||||
return f'spot {slot}' if slot is not None else f'page {page + 1}'
|
||||
|
||||
def pack_page(tiles, page):
|
||||
"""Give these tiles the cells of one page, in the order they are in."""
|
||||
position = page * SLOTS_PER_PAGE
|
||||
@@ -558,12 +646,18 @@ def pack_page(tiles, page):
|
||||
tile['slot'] = position
|
||||
position += cells_of(size)
|
||||
|
||||
def apply_tile_event(layout, action, data):
|
||||
"""The layout after one tile event. Raises ValueError with the sentence the log and the answer show."""
|
||||
def run_tile_event(layout, action, data, repeat_pages=False):
|
||||
"""(layout, tile): the layout after one tile event and the tile it placed, changed, moved or removed (None for an
|
||||
order). Raises ValueError with the sentence the log and the answer show.
|
||||
|
||||
Only a navigation tile can be on a screen more than once, and only when its firmware takes that (`repeat_pages`,
|
||||
0.2.65+). An event then names the copy it means by a spot it covers or by its page: `slot` or `page` for add and
|
||||
remove, `from_slot` or `from_page` for a move (whose `slot` and `page` say where to), and each mention in an order
|
||||
takes the next copy. Without that it acts on the first copy in spot order (app 0.2.78)."""
|
||||
result = {key: value for key, value in layout.items() if key != 'tiles'}
|
||||
tiles = [dict(tile) for tile in layout.get('tiles', [])]
|
||||
entity = str(data.get('entity') or '').strip()
|
||||
page, slot = event_page(data), None
|
||||
page = event_page(data)
|
||||
slot = event_slot(data, page)
|
||||
if slot is not None and page is None:
|
||||
page = slot // SLOTS_PER_PAGE
|
||||
@@ -574,33 +668,57 @@ def apply_tile_event(layout, action, data):
|
||||
wanted = [str(item).strip() for item in wanted if str(item).strip()]
|
||||
if not wanted:
|
||||
raise ValueError('Give the entities in the order you want them.')
|
||||
known = {tile['entity']: tile for tile in tiles}
|
||||
missing = [name for name in wanted if name not in known]
|
||||
picked, taken, missing, elsewhere, extra = [], set(), [], [], []
|
||||
for name in wanted:
|
||||
copies = copies_of(tiles, name)
|
||||
here = [tile for tile in copies if page is None or page_of(tile) == page]
|
||||
free = [tile for tile in here if id(tile) not in taken]
|
||||
if not copies:
|
||||
missing.append(name)
|
||||
elif not here:
|
||||
elsewhere.append(name)
|
||||
elif not free:
|
||||
extra.append(name)
|
||||
else:
|
||||
picked.append(free[0])
|
||||
taken.add(id(free[0]))
|
||||
if missing:
|
||||
raise ValueError('Not on this screen: ' + ', '.join(missing) + '.')
|
||||
if page is None:
|
||||
rest = [tile for tile in tiles if tile['entity'] not in wanted]
|
||||
tiles = [known[name] for name in wanted] + rest
|
||||
for tile, cell in zip(tiles, pack_slots(tiles)):
|
||||
tile['slot'] = cell
|
||||
else:
|
||||
elsewhere = [name for name in wanted if page_of(known[name]) != page]
|
||||
if elsewhere:
|
||||
raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
|
||||
on_page = [known[name] for name in wanted] + [t for t in tiles if page_of(t) == page and t['entity'] not in wanted]
|
||||
pack_page(on_page, page)
|
||||
if extra:
|
||||
raise ValueError('Named more often than it is on %s: %s.' % ('this screen' if page is None else f'page {page + 1}',
|
||||
', '.join(dict.fromkeys(extra))))
|
||||
if page is None:
|
||||
tiles = picked + [tile for tile in tiles if id(tile) not in taken]
|
||||
for tile, cell in zip(tiles, packed_slots(tiles)):
|
||||
tile['slot'] = cell
|
||||
else:
|
||||
pack_page(picked + [tile for tile in tiles if page_of(tile) == page and id(tile) not in taken], page)
|
||||
result['tiles'] = tiles
|
||||
return result
|
||||
return result, None
|
||||
if not entity:
|
||||
raise ValueError('Name the entity.')
|
||||
found = next((tile for tile in tiles if tile['entity'] == entity), None)
|
||||
copies = copies_of(tiles, entity)
|
||||
found = copies[0] if copies else None
|
||||
if action == 'remove':
|
||||
source_page, source_slot = event_source(data)
|
||||
if source_page is None:
|
||||
source_page, source_slot = page, slot
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen.')
|
||||
tiles.remove(found)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
# The copy that was named itself, not the first tile equal to it.
|
||||
tiles = [tile for tile in tiles if tile is not found]
|
||||
elif action == 'move':
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on this screen; add it first.')
|
||||
source_page, source_slot = event_source(data)
|
||||
found = pick_copy(copies, source_page, source_slot)
|
||||
if not found:
|
||||
raise ValueError(f'{entity} is not on {where(source_page, source_slot)}.')
|
||||
if page is None and slot is None:
|
||||
raise ValueError('Name the page or the spot to move it to.')
|
||||
found.pop('slot', None)
|
||||
@@ -608,6 +726,12 @@ def apply_tile_event(layout, action, data):
|
||||
else:
|
||||
if not entity_id(entity) and entity not in BUILTIN:
|
||||
raise ValueError(f'{entity} cannot go on a screen.')
|
||||
# A navigation tile the firmware takes more than once: the copy on the named spot or page changes, and anywhere
|
||||
# else a new copy goes (app 0.2.78). Every other tile, or one without a place, is the one that is there.
|
||||
chosen = False
|
||||
if copies and repeat_pages and page_target(entity) and (page is not None or slot is not None):
|
||||
found = pick_copy(copies, page, slot)
|
||||
chosen = found is not None
|
||||
was_size, had_slot = tile_size(found) if found else 'single', (found or {}).get('slot')
|
||||
options = tile_options(data, (found or {}).get('options'))
|
||||
tile = found or {'entity': entity, 'name': ''}
|
||||
@@ -622,23 +746,30 @@ def apply_tile_event(layout, action, data):
|
||||
raise ValueError(f'This screen already has {MAX_TILES} tiles; remove one first.')
|
||||
tiles.append(tile)
|
||||
size = tile_size(tile)
|
||||
move = found is None or had_slot is None or page is not None or slot is not None
|
||||
move = found is None or had_slot is None or (not chosen and (page is not None or slot is not None))
|
||||
if not move and size != was_size:
|
||||
# A tile that just grew keeps its spot when the cells it needs are free.
|
||||
start = page_start(had_slot) if size == 'full' else had_slot - had_slot % 2 if size == 'wide' else had_slot
|
||||
move = start != had_slot or bool(set(footprint(start, size)) & occupied_cells([t for t in tiles if t is not tile]))
|
||||
if move:
|
||||
tile.pop('slot', None)
|
||||
if size == 'full' and had_slot is not None and page is None and slot is None:
|
||||
if size == 'full' and had_slot is not None and ((page is None and slot is None) or chosen):
|
||||
# A tile that grew to the whole page stays on its page when the page is otherwise empty.
|
||||
try:
|
||||
place_tile(tile, tiles, had_slot // SLOTS_PER_PAGE)
|
||||
except ValueError:
|
||||
place_tile(tile, tiles)
|
||||
elif chosen:
|
||||
place_tile(tile, tiles, page_of({'slot': had_slot}))
|
||||
else:
|
||||
place_tile(tile, tiles, page, slot)
|
||||
found = tile
|
||||
result['tiles'] = tiles
|
||||
return result
|
||||
return result, found
|
||||
|
||||
def apply_tile_event(layout, action, data, repeat_pages=False):
|
||||
"""The layout after one tile event (run_tile_event without the tile it acted on)."""
|
||||
return run_tile_event(layout, action, data, repeat_pages)[0]
|
||||
|
||||
def layout_snapshot(screen, layout):
|
||||
"""What a screen shows, for the sensor the app publishes in Home Assistant: one entry per tile with
|
||||
@@ -717,7 +848,8 @@ def validate_layout(data, stored=False):
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
raise ValueError("This entity isn't supported.")
|
||||
if tile['entity'] in seen:
|
||||
# A navigation tile may go on several pages (min_firmware asks 0.2.65 for that); anything else appears once.
|
||||
if tile['entity'] in seen and not page_target(tile['entity']):
|
||||
raise ValueError('An entity can only appear once on a screen.')
|
||||
name = tile.get('name', '')
|
||||
if not isinstance(name, str) or len(name.encode()) > 80:
|
||||
@@ -796,7 +928,7 @@ def validate_layout(data, stored=False):
|
||||
item['slot'] = slot
|
||||
clean.sort(key=lambda item: item['slot'])
|
||||
else:
|
||||
for item, slot in zip(clean, pack_slots(clean)):
|
||||
for item, slot in zip(clean, packed_slots(clean)):
|
||||
item['slot'] = slot
|
||||
result = {'title': title.strip(), 'tiles': clean}
|
||||
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
|
||||
@@ -1300,13 +1432,10 @@ def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
|
||||
|
||||
One call per device: a screen that shows up twice (an old inbox next to a renamed one) counts once."""
|
||||
minimum = tuple(int(part) for part in ALERT_MIN_FIRMWARE.split('.'))
|
||||
minimum = parse_firmware(ALERT_MIN_FIRMWARE)
|
||||
ready, skipped, nodes = [], [], set()
|
||||
for screen in screens:
|
||||
try:
|
||||
version = tuple(int(part) for part in str(screen.get('firmware') or '').split('.'))
|
||||
except ValueError:
|
||||
version = ()
|
||||
version = screen_firmware(screen)
|
||||
node = screen.get('node')
|
||||
if node in nodes:
|
||||
continue
|
||||
@@ -1314,7 +1443,7 @@ def alert_targets(screens):
|
||||
skipped.append((screen, 'device name unknown'))
|
||||
elif not screen.get('online'):
|
||||
skipped.append((screen, 'offline'))
|
||||
elif len(version) != 3 or version < minimum:
|
||||
elif version is None or version < minimum:
|
||||
skipped.append((screen, f"firmware {screen.get('firmware') or 'unknown'}"))
|
||||
else:
|
||||
ready.append(screen)
|
||||
@@ -1352,9 +1481,13 @@ def discover_screens(registry, states, devices, areas):
|
||||
device = device_map.get(item.get('device_id'), {})
|
||||
state = states.get(eid, {})
|
||||
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
|
||||
firmware = versions.get(item.get('device_id'), 'unknown')
|
||||
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
|
||||
'device_id': item.get('device_id'),
|
||||
'firmware': versions.get(item.get('device_id'), 'unknown'),
|
||||
'firmware': firmware,
|
||||
# What feature gates go by: the sensor, or Home Assistant's device registry while the screen is
|
||||
# offline or restarting (app 0.2.78).
|
||||
'firmware_known': known_firmware(firmware, device.get('sw_version')),
|
||||
'board': boards.get(item.get('device_id'), 'unknown'),
|
||||
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
|
||||
'device': device.get('name') or '',
|
||||
|
||||
@@ -39,7 +39,7 @@ def profile_meta(text):
|
||||
|
||||
class Firmware:
|
||||
OVERRIDE_SUFFIX = '.local.yaml'
|
||||
OVERRIDE_LIMIT = 12 * 1024 # bytes; the request body limit is 16 KB and JSON escaping grows the text
|
||||
OVERRIDE_LIMIT = 12 * 1024 # bytes; checked here, far below the request body limit (128 KB since app 0.2.78)
|
||||
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
|
||||
'external_components', 'captive_portal'})
|
||||
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -17,8 +18,9 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, 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_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
|
||||
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
|
||||
import header_bar
|
||||
import history_card
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -635,13 +637,11 @@ class Manager:
|
||||
self.notify()
|
||||
|
||||
def firmware_version(self, inbox, screen=None):
|
||||
"""The firmware a screen's features go by: its sensor, or while that has no version (offline, restarting) the
|
||||
one Home Assistant's device registry kept (app 0.2.78); None when neither says (core.screen_firmware)."""
|
||||
if screen is None:
|
||||
screen = self.screen(inbox) or {}
|
||||
try:
|
||||
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
|
||||
return version if len(version)==3 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return screen_firmware(screen)
|
||||
|
||||
def supports_twenty(self, inbox):
|
||||
version=self.firmware_version(inbox)
|
||||
@@ -963,7 +963,11 @@ class Manager:
|
||||
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
|
||||
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
|
||||
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
|
||||
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
|
||||
# A page from before an option existed sends its tiles without it. It never sends a navigation tile twice
|
||||
# (firmware 0.2.65+), so a copy keeps exactly what it was sent with.
|
||||
old_list = self.layouts.get(inbox,{}).get('tiles',[])
|
||||
old_counts, new_counts = Counter(t['entity'] for t in old_list), Counter(t['entity'] for t in layout['tiles'])
|
||||
old_tiles = {t['entity']:t for t in old_list if old_counts[t['entity']] == 1 and new_counts[t['entity']] == 1}
|
||||
for tile in layout['tiles']:
|
||||
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
|
||||
for key in ('background', 'icon', 'controls'):
|
||||
@@ -974,7 +978,7 @@ class Manager:
|
||||
# Restored options can widen a tile: an editor without positions packs again with
|
||||
# the real widths, and explicit positions are checked once more for overlap.
|
||||
if not any(isinstance(t, dict) and 'slot' in t for t in data.get('tiles', [])):
|
||||
for tile, slot in zip(layout['tiles'], pack_slots(layout['tiles'])):
|
||||
for tile, slot in zip(layout['tiles'], packed_slots(layout['tiles'])):
|
||||
tile['slot'] = slot
|
||||
layout = validate_layout(layout)
|
||||
known = {e['id'] for e in entities} | set(BUILTIN)
|
||||
@@ -982,6 +986,13 @@ class Manager:
|
||||
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
|
||||
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
|
||||
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
|
||||
# The screen takes the layout in one message of at most 4096 bytes, which lists every tile's entity id: 48 tiles
|
||||
# with long ids can pass every rule above and still never reach the screen (app 0.2.78).
|
||||
try:
|
||||
encode(self.layout_message(inbox, layout, screen))
|
||||
except ValueError:
|
||||
raise ValueError("These tiles' entity IDs are too long together to fit in one message to the screen; "
|
||||
"remove a few tiles.") from None
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.write_layouts(updated)
|
||||
self.layouts = updated
|
||||
@@ -1132,6 +1143,12 @@ class Manager:
|
||||
return False
|
||||
tiles = layout['tiles']
|
||||
layout_msg = self.layout_message(inbox, layout, screen)
|
||||
try:
|
||||
encode(layout_msg)
|
||||
except ValueError:
|
||||
# Saved by an app before 0.2.78, which didn't check this: nothing to retry until the layout changes.
|
||||
self.status[inbox] = 'Layout saved; too large for one message to the screen, remove a few tiles'
|
||||
return False
|
||||
previous = self.sent.get(inbox)
|
||||
full = force or not previous or previous['layout'] != layout_msg or dirty is None
|
||||
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
|
||||
@@ -1459,13 +1476,16 @@ class Manager:
|
||||
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
|
||||
|
||||
async def tile_event(self, event_type, data):
|
||||
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does."""
|
||||
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does. Returns the
|
||||
screen and the tile the event acted on (None for an order)."""
|
||||
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
|
||||
inbox = self.aliases.get(screen['id'], screen['id'])
|
||||
layout = apply_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data)
|
||||
# Firmware 0.2.65+ takes a navigation tile on several pages; an older screen keeps one per page it goes to.
|
||||
repeat = (self.firmware_version(inbox, screen) or (0, 0, 0)) >= PAGE_TILE_REPEAT_MIN_FIRMWARE
|
||||
layout, tile = run_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data, repeat)
|
||||
await self.check_supported(inbox, layout)
|
||||
self.save(inbox, layout)
|
||||
return screen
|
||||
return screen, tile
|
||||
|
||||
async def tile_loop(self):
|
||||
"""Tile events (app 0.2.51+) in arrival order, apart from the sync loop, which can be busy for a
|
||||
@@ -1475,8 +1495,11 @@ class Manager:
|
||||
event_type, data = await queue.get()
|
||||
answer = {'event': event_type, 'screen': data.get('screen') or '', 'entity': data.get('entity') or ''}
|
||||
try:
|
||||
screen = await self.tile_event(event_type, data)
|
||||
screen, tile = await self.tile_event(event_type, data)
|
||||
answer.update(ok=True, screen=screen['name'])
|
||||
if tile is not None and 'slot' in tile:
|
||||
# Which tile it was, which matters for a navigation tile that is on several pages (app 0.2.78).
|
||||
answer.update(page=tile['slot'] // SLOTS_PER_PAGE + 1, slot=tile['slot'])
|
||||
LOG.info('%s: %s on %s', event_type, answer['entity'] or 'order', screen['name'])
|
||||
await self.publish_layouts()
|
||||
except Exception as error:
|
||||
@@ -1529,8 +1552,14 @@ def create_app(manager, development=False):
|
||||
response = await handler(request)
|
||||
except ValueError as error:
|
||||
return web.json_response({'error': str(error)}, status=400)
|
||||
# Home Assistant said no, or can't be reached (Identify, Try it, a setting): a sentence the page shows, never a
|
||||
# bare 500 with a traceback in the log (app 0.2.78). Refused is a ConnectionError, so it comes first.
|
||||
except Refused as error:
|
||||
return web.json_response({'error': f"Home Assistant didn't take it: {error.detail or 'no reason given'}."}, status=400)
|
||||
except (ConnectionError, TimeoutError):
|
||||
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
return web.json_response({'error': 'That is too much text for one request. Keep it below 12 KB.'}, status=413)
|
||||
return web.json_response({'error': 'That request is too large.'}, status=413)
|
||||
except (TypeError, KeyError):
|
||||
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
|
||||
if response.prepared:
|
||||
@@ -1540,9 +1569,19 @@ def create_app(manager, development=False):
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
|
||||
return response
|
||||
|
||||
app = web.Application(middlewares=[guard], client_max_size=16*1024)
|
||||
# A layout of 48 tiles with actions on their taps passes 16 KB, the limit from the twenty-tile days; the largest the
|
||||
# rules allow stays under about 75 KB (app 0.2.78). The YAML override keeps its own 12 KB limit (firmware.py).
|
||||
app = web.Application(middlewares=[guard], client_max_size=128*1024)
|
||||
static = Path(__file__).parent / 'static'
|
||||
|
||||
async def cache_assets(request, response):
|
||||
"""The built script, styles and fonts under /assets/ never change under their name (Vite names each file after a
|
||||
hash of its content), so a browser keeps them instead of fetching about 310 KB on every open (app 0.2.78).
|
||||
`private`: behind ingress the request carries Home Assistant's session. index.html and api/ stay no-store."""
|
||||
if request.path.startswith('/assets/') and response.status in (200, 304):
|
||||
response.headers['Cache-Control'] = 'private, max-age=31536000, immutable'
|
||||
app.on_response_prepare.append(cache_assets)
|
||||
|
||||
# The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles
|
||||
# by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never
|
||||
# runs an old script against a new page.
|
||||
@@ -1562,6 +1601,11 @@ def create_app(manager, development=False):
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
screen['alert_action'] = alert_service(screen.get('node'))
|
||||
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
|
||||
# What the editor may offer this screen, by the firmware the app's own checks go by (app 0.2.78): an offline
|
||||
# screen keeps its 48 tiles, and the editor needn't know the version rules.
|
||||
version = manager.firmware_version(screen['id'], screen)
|
||||
screen['firmware_known'] = version_text(version)
|
||||
screen.update(firmware_features(version))
|
||||
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
|
||||
'pending': manager.pending_profiles(screens, profiles),
|
||||
'updates': manager.updates.summary(screens, profiles)}
|
||||
@@ -1576,6 +1620,8 @@ def create_app(manager, development=False):
|
||||
payload['controls'] = controls_catalogue()
|
||||
payload['icons'] = tile_icons.editor()
|
||||
payload['alerts'] = alert_reference()
|
||||
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
|
||||
payload['changelog'] = manager.updates.changelog
|
||||
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
|
||||
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
|
||||
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
|
||||
@@ -1781,7 +1827,9 @@ async def main():
|
||||
async with ClientSession(timeout=ClientTimeout(total=20)) as session:
|
||||
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
|
||||
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None)
|
||||
# handle_signals: SIGTERM (the Supervisor stopping the app, `docker stop`) and SIGINT end the app through the
|
||||
# cleanup below, which also stops a running build, instead of waiting ten seconds for SIGKILL (app 0.2.78).
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None, handle_signals=True)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
# Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
|
||||
@@ -1801,4 +1849,6 @@ async def main():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
|
||||
# The signal arrives as GracefulExit once everything is cleaned up, as in aiohttp's own run_app: a normal stop.
|
||||
with contextlib.suppress(web.GracefulExit):
|
||||
asyncio.run(main())
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
<script type="module" crossorigin src="./assets/index-Cj3m_z3Z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BL5pkID8.css">
|
||||
<script type="module" crossorigin src="./assets/index-DfYonu9c.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DUJYFAbE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -8,17 +8,13 @@ from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import changelog
|
||||
from core import FIRMWARE_VERSION
|
||||
from core import FIRMWARE_VERSION, parse_firmware
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
NIGHT_HOURS = range(3, 6)
|
||||
|
||||
def parse_version(text):
|
||||
try:
|
||||
parts = tuple(int(part) for part in str(text).split('.'))
|
||||
except ValueError:
|
||||
return None
|
||||
return parts if len(parts) == 3 else None
|
||||
# Strict X.Y.Z, the one rule the feature gates follow too: an update goes by what the screen's sensor reports now.
|
||||
parse_version = parse_firmware
|
||||
|
||||
TARGET = parse_version(FIRMWARE_VERSION)
|
||||
|
||||
@@ -105,8 +101,9 @@ class Updater:
|
||||
'result': self.results.get(screen['id'])}
|
||||
|
||||
def summary(self, screens=None, profiles=None):
|
||||
# The changelog goes with the full inventory only (app 0.2.78): this summary is in every live update of the page.
|
||||
return {'auto': self.auto, 'target': FIRMWARE_VERSION, 'busy': self.current,
|
||||
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round, 'changelog': self.changelog}
|
||||
'pending': len(self.pending(screens, profiles)), 'last_round': self.last_round}
|
||||
|
||||
def pending(self, screens=None, profiles=None):
|
||||
# Callers that already hold the inventory and profile list pass them in; the inventory
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.77"
|
||||
version: "0.2.78"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
arch: [aarch64, amd64]
|
||||
startup: application
|
||||
boot: auto
|
||||
init: false
|
||||
homeassistant_api: true
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
|
||||
@@ -10,6 +10,7 @@ import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_data, alert_reference, alert_targets)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import tile_icons # noqa: E402
|
||||
from core import (ALERT_LIMITS, ALERT_MIN_FIRMWARE, FIRMWARE_VERSION, TILE_BACKGROUNDS, alert_reference, # noqa: E402
|
||||
alert_service)
|
||||
|
||||
+20
-4
@@ -129,18 +129,23 @@ class Clock:
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
|
||||
class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
def feed(self, answers):
|
||||
self.fetched = []
|
||||
self.fetched, self.gate = [], None
|
||||
clock = Clock()
|
||||
|
||||
async def fetch(entity):
|
||||
self.fetched.append(entity)
|
||||
answer = answers[min(len(self.fetched), len(answers)) - 1]
|
||||
if self.gate is not None:
|
||||
# A camera that takes its time: the fetch stays on its way until the test opens the gate.
|
||||
await self.gate.wait()
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
return camera_feed.CameraFeed(fetch, clock=clock), clock
|
||||
|
||||
async def settle(self):
|
||||
"""Let the fetch a load started run before counting fetches: whether it already ran when serve() returns
|
||||
depends on whether serve() had to wait for anything itself."""
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -163,18 +168,23 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
feed, clock = self.feed([first, second, third])
|
||||
token = feed.link('camera.max', (480, 480))
|
||||
_, one, tag_one = await feed.serve(token)
|
||||
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
|
||||
await self.settle()
|
||||
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(len(self.fetched), 2, 'nobody loads, nothing is fetched')
|
||||
clock.now += 4
|
||||
_, two, tag_two = await feed.serve(token, tag_one)
|
||||
await self.settle()
|
||||
self.assertNotEqual(tag_two, tag_one, 'the picture fetched after the last load')
|
||||
self.assertEqual(len(self.fetched), 3)
|
||||
# Two screens loading at once share one fetch.
|
||||
# Two screens loading while the next picture is on its way share that one fetch.
|
||||
self.gate = asyncio.Event()
|
||||
await feed.serve(token, tag_two)
|
||||
await feed.serve(token, tag_two)
|
||||
self.assertLessEqual(len(self.fetched), 4)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 4)
|
||||
self.gate.set()
|
||||
await self.settle()
|
||||
|
||||
async def test_a_camera_that_fails_is_asked_again_after_a_pause(self):
|
||||
feed, clock = self.feed([ConnectionError('500'), ConnectionError('500'), picture('JPEG', (640, 360))])
|
||||
@@ -182,22 +192,27 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertLogs(camera_feed.LOG, 'INFO'):
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 1, 'no second try within the pause')
|
||||
clock.now += 5
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 2)
|
||||
clock.now += 10
|
||||
self.assertEqual((await feed.serve(token))[0], 200)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 4, 'the good picture, then the next one')
|
||||
|
||||
async def test_an_alert_gets_a_snapshot_of_its_own_moment(self):
|
||||
first, second = picture('JPEG', (640, 360)), picture('PNG', (640, 360))
|
||||
feed, clock = self.feed([first, second])
|
||||
tag_first, _ = await feed.frame('camera.front_door', (392, 220), fresh=False)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 1)
|
||||
clock.now += 20
|
||||
# The bell rings 20 s later: the kept snapshot is not the moment, a new fetch is.
|
||||
tag_alert, _ = await feed.frame('camera.front_door', (392, 220), fresh=False, now=True)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 2)
|
||||
self.assertNotEqual(tag_alert, tag_first)
|
||||
|
||||
@@ -236,6 +251,7 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn(tokens[-1], feed.links)
|
||||
self.assertTrue(all(re.fullmatch(r'[A-Za-z0-9_-]{24}', token) for token in tokens))
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
async def test_the_http_port(self):
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
feed, _ = self.feed([picture('JPEG', (1920, 1080))])
|
||||
|
||||
@@ -27,8 +27,9 @@ int main() {
|
||||
pager.begin(2000); assert(pager.accept_repeat(2080, 12));
|
||||
pager.begin(2250); assert(pager.accept_repeat(2330, 12)); // 250 ms after the last
|
||||
pager.begin(2500); assert(pager.accept_repeat(2580, 12)); // and again
|
||||
pager.begin(2600); assert(!pager.accept(2680, 12)); // the old rule: bounce window
|
||||
pager.begin(2700); assert(pager.accept_repeat(2780, 11)); // Previous right after Next
|
||||
pager.begin(2800); assert(!pager.accept(2880, 12)); // 300 ms after the last Next: the old
|
||||
assert(pager.accept_repeat(2880, 12)); // rule's 600 ms window drops it, this takes it
|
||||
pager.begin(2950); assert(pager.accept_repeat(3030, 11)); // Previous right after Next
|
||||
cyd::TouchGuard g;
|
||||
g.begin(100);
|
||||
assert(!g.accept(120, 1)); // resistive noise pulse
|
||||
@@ -84,7 +85,8 @@ int main() {
|
||||
wide.begin(3200, 200, 200, 3);
|
||||
assert(!wide.accept(3260, 2)); // same tile within 600 ms
|
||||
assert(wide.reason() == "same button within the debounce window");
|
||||
// No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost.
|
||||
// No movement limit (0, the Guition): drift never drops a tap. The tile checks that the finger let go on it
|
||||
// (runtime_tiles::event, firmware 0.2.65+), and a quick flick is LVGL's gesture, which consumes the contact.
|
||||
cyd::TouchGuard free;
|
||||
free.configure(0, 20);
|
||||
free.begin(100, 200, 200, 3);
|
||||
@@ -96,6 +98,9 @@ int main() {
|
||||
free.update(205, 200, 3); free.update(205, 200, 3); free.update(205, 200, 3);
|
||||
free.update(400, 200, 3);
|
||||
assert(free.accept_repeat(1060, 7)); // -/+ keys follow the same rule
|
||||
free.begin(2000, 200, 200, 3);
|
||||
free.consume(); // a flick (the Guition's gesture handler)
|
||||
assert(!free.accept(2100, 1) && !free.accept_repeat(2100, 7) && !free.accept_slider(2100, 201));
|
||||
cyd::TouchGuard resistive;
|
||||
resistive.configure(56, 60);
|
||||
resistive.begin(100, 50, 50);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Live values on the mockup, Identify, the test alert and the changelog for the Update badge (app 0.2.73).
|
||||
|
||||
The editor asks /api/states for the tiles it shows and draws Home Assistant's values on the mockup; Identify and
|
||||
Try it call a screen's own show_alert action, with the same field rules as an alert event; the update summary
|
||||
Try it call a screen's own show_alert action, with the same field rules as an alert event; the full inventory
|
||||
carries the CHANGELOG sections so the badge can say what a screen gets.
|
||||
"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -37,6 +38,58 @@ class Changelog(unittest.TestCase):
|
||||
self.assertEqual(sections[0]['lines'], ['One workspace. Sidebar, pages, library with code.', 'Second line.'])
|
||||
self.assertEqual(changelog.parse(SAMPLE, limit=1)[0]['app'], '0.2.74')
|
||||
|
||||
def test_italics_go_and_lone_asterisks_and_code_stay(self):
|
||||
# What's new showed "*Full page*" (app 0.2.78).
|
||||
self.assertEqual(changelog.plain('A size can be *Full page*: (*26.0 °C*, *Off*).'), 'A size can be Full page: (26.0 °C, Off).')
|
||||
self.assertEqual(changelog.plain('5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*'), '5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*')
|
||||
self.assertEqual(changelog.plain('**Bold** with `code *x*` and [a *link*](docs/X.md)'), 'Bold with code *x* and a link')
|
||||
|
||||
def test_other_headings_star_bullets_and_wrapped_lines(self):
|
||||
text = '''# Changelog
|
||||
|
||||
## 0.2.80 (firmware 0.2.66)
|
||||
|
||||
Intro, not a bullet.
|
||||
|
||||
- **First.** It goes on
|
||||
on an indented line
|
||||
and on a line right under it.
|
||||
* A star bullet.
|
||||
- Third.
|
||||
|
||||
A second paragraph of the third one.
|
||||
|
||||
A paragraph after the list, not a bullet.
|
||||
|
||||
### Details
|
||||
|
||||
- Under a smaller heading, still this release.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Not in any release.
|
||||
|
||||
## 0.2.79 (firmware 0.2.65)
|
||||
|
||||
- Older.
|
||||
'''
|
||||
sections = changelog.parse(text)
|
||||
self.assertEqual([s['app'] for s in sections], ['0.2.80', '0.2.79'])
|
||||
self.assertEqual(sections[0]['lines'], ['First. It goes on on an indented line and on a line right under it.', 'A star bullet.',
|
||||
'Third. A second paragraph of the third one.', 'Under a smaller heading, still this release.'])
|
||||
self.assertEqual(sections[1]['lines'], ['Older.'], 'a bullet under an unknown heading belongs to no release')
|
||||
|
||||
def test_a_changelog_that_cant_be_read_leaves_the_notes_empty(self):
|
||||
# Updater() reads it when the app starts; a broken file must never stop the app (app 0.2.78).
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = Path(tmp) / 'CHANGELOG.md'
|
||||
folder.mkdir()
|
||||
latin = Path(tmp) / 'latin.md'
|
||||
latin.write_bytes('## 0.2.80 (firmware 0.2.66)\n\n- caf\xe9\n'.encode('latin-1'))
|
||||
for path in (folder, latin, Path(tmp) / 'missing.md'):
|
||||
with self.assertLogs('screen_manager', 'WARNING') if path.exists() else contextlib.nullcontext():
|
||||
self.assertEqual(changelog.load(path), [], path)
|
||||
|
||||
def test_the_real_changelog_is_found_and_shipped(self):
|
||||
sections = changelog.load()
|
||||
self.assertTrue(sections and sections[0]['lines'], 'CHANGELOG.md one folder up from the app')
|
||||
@@ -100,11 +153,33 @@ class Endpoints(unittest.IsolatedAsyncioTestCase):
|
||||
bad = await self.client.post('/api/alerts/test', headers=self.headers, json={'screen': 'text.nope'})
|
||||
self.assertEqual(bad.status, 400)
|
||||
|
||||
async def test_the_update_summary_carries_the_changelog(self):
|
||||
inventory = await (await self.client.get('/api/inventory?light=1')).json()
|
||||
sections = inventory['updates']['changelog']
|
||||
async def test_home_assistant_saying_no_or_not_answering_is_a_sentence_not_a_500(self):
|
||||
# Identify and Try it answered a bare 500 with a traceback in the log (app 0.2.78).
|
||||
from server import Refused
|
||||
cases = ((Refused('Action esphome.office_1_show_alert not found'), 400,
|
||||
"Home Assistant didn't take it: Action esphome.office_1_show_alert not found."),
|
||||
(Refused(''), 400, "Home Assistant didn't take it: no reason given."),
|
||||
(ConnectionError("Home Assistant isn't connected."), 503, "Home Assistant isn't reachable right now. Try again in a moment."),
|
||||
(TimeoutError(), 503, "Home Assistant isn't reachable right now. Try again in a moment."))
|
||||
for error, status, sentence in cases:
|
||||
async def call(action, data, error=error):
|
||||
raise error
|
||||
self.ha.call = call
|
||||
for response in (await self.client.post(f"/api/screens/{self.screen['id']}/identify", headers=self.headers),
|
||||
await self.client.post('/api/alerts/test', headers=self.headers,
|
||||
json={'screen': self.screen['id'], 'data': {'title': 'Door'}})):
|
||||
self.assertEqual((response.status, await response.json()), (status, {'error': sentence}), repr(error))
|
||||
|
||||
async def test_the_full_inventory_carries_the_changelog(self):
|
||||
# Only the full inventory (app 0.2.78): the live payload goes out every few seconds and needs no notes.
|
||||
inventory = await (await self.client.get('/api/inventory')).json()
|
||||
sections = inventory['changelog']
|
||||
self.assertTrue(sections)
|
||||
self.assertEqual(set(sections[0]), {'app', 'firmware', 'lines'})
|
||||
self.assertNotIn('changelog', inventory['updates'])
|
||||
light = await (await self.client.get('/api/inventory?light=1')).json()
|
||||
self.assertNotIn('changelog', light)
|
||||
self.assertNotIn('changelog', light['updates'])
|
||||
|
||||
|
||||
class Editor(unittest.TestCase):
|
||||
|
||||
@@ -5,10 +5,12 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
from server import KEEPALIVE_SECONDS
|
||||
from core import validate_layout, state_message, packets
|
||||
from firmware import Firmware
|
||||
from test_portal import ManagerTests
|
||||
# The module, not the class: a TestCase imported by name runs its tests a second time in this module.
|
||||
import test_portal
|
||||
|
||||
class ExtendedTests(unittest.TestCase):
|
||||
def test_options_validation_and_new_domains(self):
|
||||
@@ -53,7 +55,7 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
|
||||
except asyncio.CancelledError:pass
|
||||
async def test_old_browser_preserves_new_tile_options(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
|
||||
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
|
||||
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
|
||||
@@ -92,7 +94,7 @@ class SpecialTiles(unittest.TestCase):
|
||||
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_domains_need_firmware_and_send_extras(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
|
||||
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
|
||||
with self.assertRaises(ValueError):m.save('text.screen',layout)
|
||||
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from firmware import Firmware # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
@@ -100,8 +100,11 @@ class Layouts(unittest.TestCase):
|
||||
for entity in ('screen.page_9', 'screen.page', 'screen.page_0'):
|
||||
with self.assertRaisesRegex(ValueError, "isn't supported"):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile(entity, 0)]})
|
||||
# The same navigation tile on several pages is firmware 0.2.65's (app 0.2.78); any other entity stays once.
|
||||
twice = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 7)]})
|
||||
self.assertEqual(min_firmware(twice), (0, 2, 65))
|
||||
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 1)]})
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.a', 1)]})
|
||||
|
||||
def test_snapshot_and_wire(self):
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('light.a', 6, size='full')]})
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The firmware a screen's features go by, and what the editor may offer it (app 0.2.78).
|
||||
|
||||
A screen whose "Screen firmware" sensor had no version (offline, restarting) counted as firmware 0.0.0: a save of more
|
||||
than ten tiles or of a newer kind of tile was refused, and the editor capped the screen at ten tiles. Home Assistant's
|
||||
device registry keeps the version ESPHome reported, "<project version> (ESPHome <version>)" for our firmware, and the app
|
||||
now falls back to that. Only a plain X.Y.Z counts, as before: "0.2.65-dev" is no release.
|
||||
|
||||
Each screen in the inventory says what that means for the editor: `firmware_known`, `tile_limit`, `full_page` and
|
||||
`page_tiles_repeat` (the contract with web/src/store.ts).
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import camera_feed # noqa: E402
|
||||
from core import (alert_targets, discover_screens, firmware_features, known_firmware, parse_firmware, registry_firmware, # noqa: E402
|
||||
screen_firmware, tile_limit)
|
||||
import updates # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
REGISTRY = '0.2.63 (ESPHome 2026.6.2)'
|
||||
|
||||
|
||||
class Versions(unittest.TestCase):
|
||||
def test_only_a_plain_version_counts(self):
|
||||
self.assertEqual(parse_firmware('0.2.63'), (0, 2, 63))
|
||||
self.assertEqual(parse_firmware('1.10.0'), (1, 10, 0))
|
||||
for text in ('0.2.65-dev', '0.2', '0.2.63.1', ' 0.2.63', '0.2.+63', REGISTRY, 'unknown', 'unavailable', '', None, 63):
|
||||
self.assertIsNone(parse_firmware(text), text)
|
||||
self.assertIs(updates.parse_version, parse_firmware, 'update offers follow the same strict rule')
|
||||
|
||||
def test_the_registry_version_of_our_firmware(self):
|
||||
self.assertEqual(registry_firmware(REGISTRY), '0.2.63')
|
||||
# Firmware without `esphome: project:` has ESPHome's own version there, which is no screen firmware.
|
||||
for text in ('2026.6.2 (Sep 17 2026, 10:00:00)', '2026.6.2', '0.2.65-dev (ESPHome 2026.6.2)', 'v0.2.63 (ESPHome 2026.6.2)', '', None):
|
||||
self.assertIsNone(registry_firmware(text), text)
|
||||
self.assertEqual(known_firmware('0.2.64', REGISTRY), '0.2.64', 'the sensor comes first')
|
||||
for sensor in ('unavailable', 'unknown', '0.2.65-dev', None):
|
||||
self.assertEqual(known_firmware(sensor, REGISTRY), '0.2.63', sensor)
|
||||
self.assertIsNone(known_firmware('unavailable', None))
|
||||
|
||||
def test_what_a_firmware_may_be_offered(self):
|
||||
self.assertEqual([tile_limit(v) for v in (None, (0, 2, 6), (0, 2, 7), (0, 2, 61), (0, 2, 62), (0, 3, 0))], [10, 10, 20, 20, 48, 48])
|
||||
self.assertEqual(firmware_features((0, 2, 64)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': False})
|
||||
self.assertEqual(firmware_features((0, 2, 65)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': True})
|
||||
self.assertEqual(firmware_features(None), {'tile_limit': 10, 'full_page': False, 'page_tiles_repeat': False})
|
||||
|
||||
def test_discovery_falls_back_to_the_registry(self):
|
||||
registry = [{'entity_id': 'text.hall_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.hall_screen_firmware', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
devices = [{'id': 'd1', 'name': 'Hall', 'sw_version': REGISTRY}]
|
||||
for sensor, known in (('0.2.64', '0.2.64'), ('unavailable', '0.2.63'), ('0.2.65-dev', '0.2.63')):
|
||||
screen, = discover_screens(registry, {'sensor.hall_screen_firmware': {'state': sensor}}, devices, [])
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known']), (sensor, known))
|
||||
self.assertEqual(screen_firmware(screen), parse_firmware(known))
|
||||
screen, = discover_screens(registry, {}, [{'id': 'd1', 'name': 'Hall'}], [])
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known'], screen_firmware(screen)), ('unknown', None, None))
|
||||
|
||||
def test_alerts_and_camera_images_go_by_the_same_version(self):
|
||||
restarting = {'id': 'text.hall', 'name': 'Hall', 'node': 'hall', 'online': True, 'board': 'guition',
|
||||
'firmware': 'unknown', 'firmware_known': '0.2.63'}
|
||||
self.assertEqual(alert_targets([restarting]), ([restarting], []))
|
||||
self.assertTrue(camera_feed.can_show(restarting))
|
||||
unknown = {**restarting, 'firmware_known': None}
|
||||
self.assertEqual(alert_targets([unknown])[1], [(unknown, 'firmware unknown')])
|
||||
self.assertFalse(camera_feed.can_show(unknown))
|
||||
|
||||
|
||||
def tiles(count, prefix='light.l'):
|
||||
return [{'entity': f'{prefix}{i}', 'name': ''} for i in range(count)]
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Saving(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, sensor, sw_version=None, online=True):
|
||||
ha = test_scaling.fake_ha(firmware=sensor)
|
||||
for tile in tiles(30):
|
||||
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
|
||||
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
|
||||
if sw_version:
|
||||
ha.devices[0]['sw_version'] = sw_version
|
||||
if not online:
|
||||
ha.states['text.screen']['state'] = 'unavailable'
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def test_an_offline_screen_keeps_its_features(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unavailable', REGISTRY, online=False)
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 30)
|
||||
self.assertEqual(m.status['text.screen'], 'Saved; waiting for sync')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unavailable', online=False)
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.62 or newer first'):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
|
||||
|
||||
async def test_a_development_version_is_no_release(self):
|
||||
back = [{'entity': 'screen.page_1', 'name': '', 'slot': 6}, {'entity': 'screen.page_1', 'name': '', 'slot': 12}]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65-dev')
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': back})
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unknown', '0.2.65 (ESPHome 2026.6.2)')
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': back})
|
||||
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [6, 12])
|
||||
|
||||
async def test_every_screen_in_the_inventory_says_what_the_editor_may_offer(self):
|
||||
cases = (('0.2.6', None, '0.2.6', 10, False, False), ('0.2.7', None, '0.2.7', 20, False, False),
|
||||
('0.2.62', None, '0.2.62', 48, True, False), ('0.2.65', None, '0.2.65', 48, True, True),
|
||||
('unavailable', REGISTRY, '0.2.63', 48, True, False), ('0.2.65-dev', None, None, 10, False, False))
|
||||
for sensor, sw_version, known, limit, full, repeat in cases:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, sensor, sw_version)
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
for path in ('/api/inventory', '/api/inventory?light=1'):
|
||||
screen, = (await (await client.get(path)).json())['screens']
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known'], screen['tile_limit'], screen['full_page'],
|
||||
screen['page_tiles_repeat']), (sensor, known, limit, full, repeat), (path, sensor))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from firmware import Firmware # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
@@ -149,9 +150,12 @@ class OverrideRoutes(unittest.IsolatedAsyncioTestCase):
|
||||
response = await client.put(url, json={'content': 'api:\n reboot_timeout: 0s\n'}, headers=headers)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn('api', (await response.json())['error'])
|
||||
# The override keeps its own 12 KB limit; the request limit is far above it (app 0.2.78).
|
||||
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
|
||||
self.assertEqual(response.status, 413)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn('12 KB', (await response.json())['error'])
|
||||
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 40000}, headers=headers)
|
||||
self.assertEqual((response.status, (await response.json())['error']), (413, 'That request is too large.'))
|
||||
self.assertEqual((await (await client.get(url)).json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
|
||||
self.assertEqual((await (await client.get('/api/firmware')).json())['profiles'], [{'file': 'kitchen.yaml'}])
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ new version is always fetched and an old one is never combined with a new page.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,12 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / 'screen_manager/app/static'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
|
||||
class Build(unittest.TestCase):
|
||||
@@ -30,12 +37,6 @@ class Build(unittest.TestCase):
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_server_serves_the_page_and_its_hashed_assets(self):
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
script = re.search(r'src="\./(assets/index-[\w-]+\.js)"', page)[1]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -50,6 +51,45 @@ class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual((asset.status, await asset.read()), (200, (STATIC / script).read_bytes()))
|
||||
self.assertEqual((await client.get('/static/app.js')).status, 404)
|
||||
|
||||
async def test_hashed_assets_are_kept_and_the_page_and_api_never(self):
|
||||
# A hashed file never changes under its name: the browser keeps it instead of fetching ~310 KB on every open
|
||||
# (app 0.2.78). `private`, as the request behind ingress carries Home Assistant's session.
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
names = re.findall(r'(?:src|href)="\./(assets/[^"]+)"', page)
|
||||
self.assertTrue(any(name.endswith('.js') for name in names) and any(name.endswith('.css') for name in names), names)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
for name in names:
|
||||
asset = await client.get(f'/{name}')
|
||||
self.assertEqual((asset.status, asset.headers['Cache-Control']), (200, 'private, max-age=31536000, immutable'), name)
|
||||
again = await client.get(f'/{name}', headers={'If-None-Match': asset.headers['ETag']})
|
||||
self.assertEqual((again.status, again.headers['Cache-Control']), (304, 'private, max-age=31536000, immutable'), name)
|
||||
missing = await client.get('/assets/index-nothere.js')
|
||||
self.assertEqual(missing.status, 404)
|
||||
self.assertNotIn('immutable', missing.headers.get('Cache-Control', ''))
|
||||
for path in ('/', '/api/inventory', '/api/inventory?light=1'):
|
||||
self.assertEqual((await client.get(path)).headers['Cache-Control'], 'no-store', path)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Guard(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_only_ingress_reaches_the_app_and_a_change_needs_the_page_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
# Production only answers Home Assistant's ingress proxy (172.30.32.2), not a request from anywhere else.
|
||||
async with TestClient(TestServer(create_app(manager))) as client:
|
||||
for path in ('/', '/api/inventory', '/assets/x.js'):
|
||||
response = await client.get(path)
|
||||
self.assertEqual((response.status, await response.text()), (403, 'Open this page through Home Assistant.'), path)
|
||||
# The development server answers localhost, and a change still needs the token the page got.
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
self.assertEqual((await client.get('/api/inventory')).status, 200)
|
||||
for headers in ({}, {'X-Screen-CSRF': 'guessed'}):
|
||||
response = await client.put('/api/updates', json={'auto': True}, headers=headers)
|
||||
self.assertEqual((response.status, await response.text()), (403, 'Refresh this page and try again.'))
|
||||
self.assertFalse(manager.updates.auto)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The same navigation tile on several pages (app 0.2.78, firmware 0.2.65): a "Back to page 1" on every page.
|
||||
|
||||
Every other entity is on a screen once. A layout with copies needs firmware 0.2.65, so an older screen gets the usual
|
||||
"Install screen firmware" refusal. A tile event from Claude in Home Assistant names the copy it means by its spot or its
|
||||
page (`from_slot`/`from_page` for a move) and acts on the first copy, in spot order, when it doesn't; the answer says
|
||||
which tile it was, and the layout sensor lists every copy.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (PAGE_TILE_REPEAT_MIN_FIRMWARE, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, min_firmware, # noqa: E402
|
||||
run_tile_event, validate_layout)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_tile_events
|
||||
from server import Manager
|
||||
|
||||
BACK = 'screen.page_1'
|
||||
|
||||
|
||||
def tile(entity, slot, **options):
|
||||
item = {'entity': entity, 'name': '', 'slot': slot}
|
||||
if options:
|
||||
item['options'] = options
|
||||
return item
|
||||
|
||||
|
||||
def layout(*tiles):
|
||||
return {'title': 'Hall', 'tiles': [dict(t) for t in tiles]}
|
||||
|
||||
|
||||
def places(result):
|
||||
return [(t['entity'], t['slot']) for t in sorted(result['tiles'], key=lambda t: t['slot'])]
|
||||
|
||||
|
||||
# A light on page 1, and the way back on pages 2 and 3.
|
||||
MENU = layout(tile('light.a', 0), tile(BACK, 6, icon='home'), tile('light.b', 7), tile(BACK, 12))
|
||||
|
||||
|
||||
class Layouts(unittest.TestCase):
|
||||
def test_copies_of_a_navigation_tile_need_firmware_0_2_65(self):
|
||||
result = validate_layout(MENU)
|
||||
self.assertEqual(places(result), [('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 12)])
|
||||
self.assertEqual(PAGE_TILE_REPEAT_MIN_FIRMWARE, (0, 2, 65))
|
||||
self.assertEqual(min_firmware(result), (0, 2, 65))
|
||||
self.assertEqual(min_firmware(validate_layout(layout(tile('light.a', 0), tile(BACK, 6)))), (0, 2, 62), 'one copy: as before')
|
||||
# Two on one page is odd but harmless, and what the editor allows too.
|
||||
validate_layout(layout(tile(BACK, 0), tile(BACK, 1)))
|
||||
for entity in ('light.a', 'screen.clock', 'screen.settings'):
|
||||
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
||||
validate_layout(layout(tile(entity, 0), tile(entity, 6)))
|
||||
|
||||
def test_the_sensor_lists_every_copy(self):
|
||||
snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, validate_layout(MENU))
|
||||
self.assertEqual([(t['entity'], t['page'], t['slot'], t.get('to_page')) for t in snapshot['tiles']],
|
||||
[('light.a', 1, 0, None), (BACK, 2, 6, 1), ('light.b', 2, 7, None), (BACK, 3, 12, 1)])
|
||||
|
||||
|
||||
class Events(unittest.TestCase):
|
||||
def test_add_puts_a_copy_on_a_page_without_one_and_changes_the_one_that_is_there(self):
|
||||
result, placed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 4, 'name': 'Back'}, repeat_pages=True)
|
||||
self.assertEqual(places(result)[-1], (BACK, 18))
|
||||
self.assertEqual((placed['slot'], placed['name']), (18, 'Back'))
|
||||
# Named by the page it is on: that copy changes and stays where it is.
|
||||
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 3, 'color': 'blue'}, repeat_pages=True)
|
||||
self.assertEqual(places(result), places(MENU))
|
||||
self.assertEqual((changed['slot'], changed['options']), (12, {'background': 'blue'}))
|
||||
self.assertEqual(next(t for t in result['tiles'] if t['slot'] == 6)['options'], {'icon': 'home'}, 'the other copy is untouched')
|
||||
# Named by nothing: the first copy.
|
||||
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'name': 'Home'}, repeat_pages=True)
|
||||
self.assertEqual((changed['slot'], len(result['tiles'])), (6, 4))
|
||||
|
||||
def test_older_firmware_keeps_one_and_adding_it_again_moves_it(self):
|
||||
start = layout(tile('light.a', 0), tile(BACK, 6))
|
||||
result, moved = run_tile_event(start, 'add', {'entity': BACK, 'page': 3})
|
||||
self.assertEqual(places(result), [('light.a', 0), (BACK, 12)])
|
||||
self.assertEqual(moved['slot'], 12)
|
||||
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': BACK, 'page': 3})), places(result), 'the old call is the same')
|
||||
|
||||
def test_remove_takes_the_copy_named_or_the_first(self):
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK})), [('light.a', 0), ('light.b', 7), (BACK, 12)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 3})), [('light.a', 0), (BACK, 6), ('light.b', 7)])
|
||||
result, removed = run_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 12})
|
||||
self.assertEqual((removed['slot'], len(result['tiles'])), (12, 3))
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'from_slot': 12})), places(result))
|
||||
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on page 4'):
|
||||
apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 4})
|
||||
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on spot 7'):
|
||||
apply_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 7})
|
||||
|
||||
def test_move_takes_the_copy_named_by_from_page_or_from_slot_or_the_first(self):
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'page': 5})),
|
||||
[('light.a', 0), ('light.b', 7), (BACK, 12), (BACK, 24)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 3, 'page': 5})),
|
||||
[('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 24)])
|
||||
result, moved = run_tile_event(MENU, 'move', {'entity': BACK, 'from_slot': 12, 'slot': 13})
|
||||
self.assertEqual((moved['slot'], places(result)[-1]), (13, (BACK, 13)))
|
||||
with self.assertRaisesRegex(ValueError, 'is not on page 1'):
|
||||
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 1, 'page': 5})
|
||||
with self.assertRaisesRegex(ValueError, 'page between 1 and 8'):
|
||||
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 9, 'page': 5})
|
||||
|
||||
def test_order_takes_the_next_copy_each_time_it_is_named(self):
|
||||
# The whole screen: named once, the first copy leads and the other keeps its place in the order.
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK]})),
|
||||
[(BACK, 0), ('light.a', 1), ('light.b', 2), (BACK, 3)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK, 'light.b', BACK]})),
|
||||
[(BACK, 0), ('light.b', 1), (BACK, 2), ('light.a', 3)])
|
||||
with self.assertRaisesRegex(ValueError, 'Named more often than it is on this screen: screen.page_1'):
|
||||
apply_tile_event(MENU, 'order', {'entities': [BACK, BACK, BACK]})
|
||||
# One page: only the copy on that page.
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'page': 2, 'entities': ['light.b', BACK]})),
|
||||
[('light.a', 0), ('light.b', 6), (BACK, 7), (BACK, 12)])
|
||||
with self.assertRaisesRegex(ValueError, 'Named more often than it is on page 2'):
|
||||
apply_tile_event(MENU, 'order', {'page': 2, 'entities': [BACK, BACK]})
|
||||
self.assertIsNone(run_tile_event(MENU, 'order', {'entities': [BACK]})[1], 'an order acts on no single tile')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Through(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, firmware):
|
||||
ha = test_tile_events.fake_ha()
|
||||
ha.states['sensor.d1_fw']['state'] = firmware
|
||||
ha.registry.append({'entity_id': 'light.a', 'platform': 'demo', 'original_name': 'A', 'device_id': 'd9'})
|
||||
ha.states['light.a'] = {'state': 'on', 'attributes': {'friendly_name': 'A'}}
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def events(self, m, *events):
|
||||
for data in events:
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', data))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(300):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(m.ha.fired) == len(events):
|
||||
break
|
||||
worker.cancel()
|
||||
return [answer for _, answer in m.ha.fired]
|
||||
|
||||
async def test_firmware_0_2_65_takes_a_copy_on_every_page_and_the_answer_says_where(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65')
|
||||
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile('light.reading', 6)]})
|
||||
answers = await self.events(m, {'entity': BACK, 'page': 2, 'name': 'Back'}, {'entity': BACK, 'page': 3, 'name': 'Back'})
|
||||
self.assertEqual([(a['ok'], a['page'], a['slot']) for a in answers], [(True, 2, 7), (True, 3, 12)], answers)
|
||||
self.assertEqual(answers[0]['event'], 'esp_screens_add_tile')
|
||||
saved = json.loads(m.path.read_text())['screens']['text.d1_tiles']['tiles']
|
||||
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.a', 0), ('light.reading', 6), (BACK, 7), (BACK, 12)])
|
||||
_, attributes = m.ha.published['sensor.esp_screens_living_room']
|
||||
self.assertEqual([t['slot'] for t in attributes['tiles'] if t['entity'] == BACK], [7, 12])
|
||||
self.assertTrue(all(fired == TILE_RESULT_EVENT for fired, _ in m.ha.fired))
|
||||
|
||||
async def test_older_firmware_moves_the_one_it_has_and_refuses_copies_from_the_editor(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.63')
|
||||
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile(BACK, 6)]})
|
||||
answers = await self.events(m, {'entity': BACK, 'page': 3})
|
||||
self.assertEqual((answers[0]['ok'], answers[0]['page'], answers[0]['slot']), (True, 3, 12), answers)
|
||||
self.assertEqual([t['slot'] for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [12])
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
|
||||
m.save('text.d1_tiles', MENU)
|
||||
|
||||
async def test_each_copy_keeps_its_own_settings_when_saved(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65')
|
||||
m.ha.registry.append({'entity_id': 'light.b', 'platform': 'demo', 'original_name': 'B', 'device_id': 'd9'})
|
||||
m.ha.states['light.b'] = {'state': 'on', 'attributes': {'friendly_name': 'B'}}
|
||||
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12, icon='home')))
|
||||
# Both copies sent without options keep none: a page from before an option existed gets its old options back,
|
||||
# but such a page never sends copies, and one copy must never take another's icon.
|
||||
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12)))
|
||||
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [None, None])
|
||||
m.save('text.d1_tiles', MENU)
|
||||
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [{'icon': 'home'}, None])
|
||||
m.ha.messages = []
|
||||
|
||||
async def send(inbox, message, action=None, respond=False):
|
||||
m.ha.messages.append(message)
|
||||
m.ha.send = send
|
||||
await m.sync_one('text.d1_tiles', m.layouts['text.d1_tiles'])
|
||||
self.assertEqual(m.ha.messages[0]['entities'], ['light.a', BACK, 'light.b', BACK])
|
||||
self.assertEqual(m.ha.messages[0]['slots'], [0, 6, 7, 12])
|
||||
self.assertEqual([msg['i'] for msg in m.ha.messages if msg['op'] == 'state' and msg['entity'] == BACK], [1, 3])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import MAX_SLOTS, has_gaps, pack_slots, validate_layout
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Release lint (app 0.2.78): the version numbers of a release agree, and the packages fetch only fonts that exist.
|
||||
|
||||
Every push to main is a release (docs/RELEASING.md), so these run with the rest of the suite in tools/check.sh and CI.
|
||||
Standard library only.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import FIRMWARE_VERSION # noqa: E402
|
||||
|
||||
CHANGELOG = ROOT / 'screen_manager/CHANGELOG.md'
|
||||
# An app release: "## 0.2.76 (firmware 0.2.63)"; the oldest ones name no firmware. The two "## Firmware 0.2.1x"
|
||||
# sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count.
|
||||
APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M)
|
||||
FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)')
|
||||
# The generator (tools/generate_packages.py) turns fonts/<file> into a raw GitHub URL on main.
|
||||
FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)')
|
||||
|
||||
|
||||
def app_headings():
|
||||
"""[(version tuple, text after the version)] of the CHANGELOG, top to bottom."""
|
||||
return [((int(a), int(b), int(c)), rest) for a, b, c, rest in APP_HEADING.findall(CHANGELOG.read_text())]
|
||||
|
||||
|
||||
def dotted(version):
|
||||
return '.'.join(map(str, version))
|
||||
|
||||
|
||||
class ReleaseVersionTests(unittest.TestCase):
|
||||
def test_config_version_is_the_newest_changelog_entry(self):
|
||||
match = re.search(r'^version:\s*["\']?([^"\'\s]+)["\']?\s*$', (ROOT / 'screen_manager/config.yaml').read_text(), re.M)
|
||||
self.assertIsNotNone(match, 'screen_manager/config.yaml has no version line')
|
||||
headings = app_headings()
|
||||
self.assertTrue(headings, 'screen_manager/CHANGELOG.md has no "## x.y.z" heading')
|
||||
self.assertEqual(match.group(1), dotted(headings[0][0]),
|
||||
'Home Assistant offers the update by config.yaml: bump it together with a new CHANGELOG entry')
|
||||
|
||||
def test_newest_entry_names_the_shipped_firmware(self):
|
||||
version, rest = app_headings()[0]
|
||||
firmware = FIRMWARE_IN_HEADING.search(rest)
|
||||
if firmware:
|
||||
self.assertEqual(firmware.group(1), FIRMWARE_VERSION,
|
||||
f'CHANGELOG {dotted(version)} names firmware {firmware.group(1)}, core.FIRMWARE_VERSION is {FIRMWARE_VERSION}')
|
||||
|
||||
def test_changelog_versions_are_unique_and_newest_first(self):
|
||||
versions = [version for version, _ in app_headings()]
|
||||
duplicates = sorted({dotted(v) for v in versions if versions.count(v) > 1})
|
||||
self.assertEqual(duplicates, [], 'a CHANGELOG version appears twice')
|
||||
for newer, older in zip(versions, versions[1:]):
|
||||
self.assertGreater(newer, older, f'CHANGELOG {dotted(newer)} stands above {dotted(older)}')
|
||||
|
||||
|
||||
class PackageFontTests(unittest.TestCase):
|
||||
def test_every_font_a_package_fetches_is_in_the_tree(self):
|
||||
packages = sorted((ROOT / 'packages').glob('*.yaml'))
|
||||
self.assertTrue(packages, 'no packages/*.yaml')
|
||||
seen = 0
|
||||
for package in packages:
|
||||
for path in FONT_URL.findall(package.read_text()):
|
||||
seen += 1
|
||||
self.assertTrue((ROOT / path).is_file(), f'{package.name} fetches {path}, which is not in the tree')
|
||||
# A changed URL form must not turn this into a test of nothing.
|
||||
self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import extras, packets, state_message
|
||||
import test_portal
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import validate_settings
|
||||
|
||||
|
||||
@@ -186,3 +186,68 @@ static void test_slider_colours() {
|
||||
setpoint.state = "55.0"; assert(setpoint.slider_active());
|
||||
}
|
||||
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
|
||||
// A navigation tile may sit on several pages of one screen (firmware 0.2.65+); a Home Assistant entity still appears once.
|
||||
static void test_repeated_page_tiles() {
|
||||
using namespace runtime_tiles;
|
||||
Model m; bool changed = false, moved = false;
|
||||
std::array<Placement, MAX_TILES> p;
|
||||
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
|
||||
assert(changed && m.count == 4 && place(m, p) == 3 && p[1].page == 1 && p[2].page == 2);
|
||||
assert(m.tiles[1].is_page() && m.tiles[2].is_page() && m.tiles[1].page_target() == 1 && m.tiles[2].page_target() == 1);
|
||||
// Each copy is its own tile: a state message reaches it by its index, so both get their options.
|
||||
assert(m.accepts(1, "screen.page_1") && m.accepts(2, "screen.page_1") && !m.accepts(3, "screen.page_1"));
|
||||
m.tiles[1].icon = "a"; m.tiles[2].icon = "b";
|
||||
assert(m.tiles[1].icon != m.tiles[2].icon);
|
||||
for (size_t i = 0; i < m.count; ++i) m.tiles[i].received = true;
|
||||
assert(m.ready());
|
||||
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
|
||||
assert(!changed && !moved && m.ready()); // the repeat of the same layout keeps everything
|
||||
assert(m.set_layout({"screen.page_2", "screen.page_2", "screen.page_2"}, "Menu", changed));
|
||||
// Every Home Assistant entity and the other built-in cards stay unique.
|
||||
assert(!m.set_layout({"light.x", "screen.page_1", "light.x"}, "Home", changed, {0, 6, 12}, moved));
|
||||
assert(!m.set_layout({"screen.clock", "screen.clock"}, "Home", changed));
|
||||
assert(!m.set_layout({"screen.settings", "screen.page_1", "screen.settings"}, "Home", changed));
|
||||
assert(!m.set_layout({"screen.page_9", "screen.page_9"}, "Home", changed));
|
||||
assert(m.count == 3 && m.title == "Menu"); // a refused layout leaves the one on screen alone
|
||||
}
|
||||
struct RunRepeatedPages { RunRepeatedPages() { test_repeated_page_tiles(); } } run_repeated_pages;
|
||||
// A longer list needs one block for every tile (firmware 0.2.65+): without it the layout is refused before anything
|
||||
// changes, with a reason for the manager, instead of std::vector writing tiles through a null pointer on the ESP32.
|
||||
static size_t test_room = 0;
|
||||
static void test_tile_room() {
|
||||
using namespace runtime_tiles;
|
||||
assert(!tile_room); // the host has room and asks nothing
|
||||
Model m; bool changed = false;
|
||||
std::vector<std::string> twelve, forty_eight;
|
||||
for (int i = 0; i < 48; ++i) (i < 12 ? twelve : forty_eight).push_back("light.r" + std::to_string(i));
|
||||
for (int i = 0; i < 12; ++i) forty_eight.push_back("light.r" + std::to_string(i));
|
||||
assert(m.set_layout(twelve, "Twelve", changed) && m.count == 12 && m.refusal.empty());
|
||||
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
|
||||
tile_room = [] { return test_room; };
|
||||
test_room = 48 * sizeof(Tile) - 1;
|
||||
assert(!m.set_layout(forty_eight, "Forty-eight", changed));
|
||||
assert(m.refusal == "Error: no memory for 48 tiles");
|
||||
assert(m.configured && m.count == 12 && m.title == "Twelve" && m.ready() && m.tiles[0].state == "on");
|
||||
// Fewer tiles than the list already holds need no new block, and a malformed layout is no memory question.
|
||||
assert(m.set_layout({"light.r0", "light.r1"}, "Two", changed) && changed && m.count == 2 && m.refusal.empty());
|
||||
assert(!m.set_layout({"light.r0", "light.r0"}, "Twice", changed) && m.refusal.empty());
|
||||
test_room = 48 * sizeof(Tile);
|
||||
assert(m.set_layout(forty_eight, "Forty-eight", changed) && m.count == 48 && m.refusal.empty());
|
||||
// An empty screen asks for its first tiles the same way.
|
||||
Model fresh;
|
||||
test_room = 0;
|
||||
assert(!fresh.set_layout({"light.a"}, "One", changed) && !fresh.configured && fresh.refusal == "Error: no memory for 1 tiles");
|
||||
assert(fresh.set_layout({}, "None", changed) && fresh.configured);
|
||||
tile_room = nullptr;
|
||||
}
|
||||
struct RunTileRoom { RunTileRoom() { test_tile_room(); } } run_tile_room;
|
||||
// One redraw bit for each of the 48 tiles (firmware 0.2.65+); 32 bits sent tiles 33-48 through a full redraw.
|
||||
static void test_tile_bits() {
|
||||
using namespace runtime_tiles;
|
||||
assert(tile_bit(0) == 1 && tile_bit(31) == (uint64_t{1} << 31) && tile_bit(47) == (uint64_t{1} << 47));
|
||||
assert(tile_bit(MAX_TILES - 1) && !tile_bit(64) && !tile_bit(MAX_TILES + 100));
|
||||
uint64_t dirty = tile_bit(33) | tile_bit(47);
|
||||
assert((dirty & tile_bit(33)) && (dirty & tile_bit(47)) && !(dirty & tile_bit(1)) && !(dirty & tile_bit(32)));
|
||||
for (size_t a = 0; a < MAX_TILES; ++a) for (size_t b = a + 1; b < MAX_TILES; ++b) assert(!(tile_bit(a) & tile_bit(b)));
|
||||
}
|
||||
struct RunTileBits { RunTileBits() { test_tile_bits(); } } run_tile_bits;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""What a layout of forty-eight tiles runs into, refused when it is saved instead of later (app 0.2.78).
|
||||
|
||||
- The screen takes the layout in one message of at most 4096 bytes that lists every entity id: 48 tiles with long ids
|
||||
passed Save and then never reached the screen.
|
||||
- Tiles without positions are packed in order: 48 with one double-width, or 43 with a full-page one that isn't first,
|
||||
ran past the eighth page and failed later with "Invalid tile position".
|
||||
- The request limit was 16 KB from the twenty-tile days, and a valid 48-tile layout with tap actions is larger.
|
||||
- The layout sensor keeps under the 16 KB Home Assistant's recorder stores of a state's attributes.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (ACTION_MAX_BYTES, MAX_TILES, apply_tile_event, encode, layout_snapshot, pack_slots, packed_slots, # noqa: E402
|
||||
validate_layout, validate_tap_action, action_for_screen)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
TOO_LONG = "These tiles' entity IDs are too long together to fit in one message to the screen; remove a few tiles."
|
||||
NO_ROOM = "These tiles don't fit on eight pages; remove a tile or make one smaller."
|
||||
# Home Assistant's recorder keeps a state's attributes up to this many bytes of JSON.
|
||||
RECORDER_ATTRIBUTES = 16384
|
||||
|
||||
|
||||
def entity(index, length, domain='light'):
|
||||
stem = f'{domain}.tile_{index:02d}_'
|
||||
return stem + 'x' * (length - len(stem))
|
||||
|
||||
|
||||
def tiles(count, length=12, **options):
|
||||
return [{'entity': entity(i, length), 'name': '', **({'options': dict(options)} if options else {})} for i in range(count)]
|
||||
|
||||
|
||||
class Packing(unittest.TestCase):
|
||||
def test_tiles_that_run_past_the_last_page_are_refused_at_once(self):
|
||||
wide = tiles(MAX_TILES)
|
||||
wide[5]['options'] = {'size': 'wide'}
|
||||
self.assertGreaterEqual(max(pack_slots(wide)), 48, 'what used to be stored')
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
validate_layout({'title': 'Hall', 'tiles': wide})
|
||||
full = tiles(43)
|
||||
full[1]['options'] = {'size': 'full'}
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
packed_slots(full)
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
validate_layout({'title': 'Hall', 'tiles': full})
|
||||
# The same tiles fit with the full page first, and 48 single tiles fill every spot.
|
||||
first = [full[1]] + full[:1] + full[2:]
|
||||
self.assertEqual(validate_layout({'title': 'Hall', 'tiles': first})['tiles'][-1]['slot'], 47)
|
||||
self.assertEqual(packed_slots(tiles(MAX_TILES)), list(range(48)))
|
||||
|
||||
def test_ordering_the_whole_screen_says_so_too(self):
|
||||
placed = [{**tile, 'slot': slot} for tile, slot in zip(tiles(43), [0] + list(range(6, 48)))]
|
||||
placed[0]['options'] = {'size': 'full'}
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': placed})
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
apply_tile_event(layout, 'order', {'entities': [entity(1, 12)]})
|
||||
|
||||
|
||||
class Snapshot(unittest.TestCase):
|
||||
def test_the_layout_sensor_stays_within_what_the_recorder_keeps(self):
|
||||
action = {'action': 'light.turn_on', 'data': {'brightness_pct': 40, 'transition': 2, 'color_name': 'warm' + 'x' * 40}}
|
||||
layout = validate_layout({'title': 'T' * 96, 'tiles': [
|
||||
{'entity': entity(i, 60), 'name': 'N' * 30, 'slot': i,
|
||||
'options': {'size': 'single', 'controls': 'none', 'display': 'standard', 'tap': 'action' if i % 12 == 0 else 'detail',
|
||||
**({'action': action} if i % 12 == 0 else {})}} for i in range(MAX_TILES)]})
|
||||
snapshot = layout_snapshot({'name': 'Living room screen', 'node': 'living-room-screen'}, layout)
|
||||
# What publish_layouts hands Home Assistant.
|
||||
attributes = {'friendly_name': 'Living room screen tiles', 'icon': 'mdi:view-dashboard-outline', **snapshot}
|
||||
size = len(json.dumps(attributes, ensure_ascii=False, separators=(',', ':')).encode())
|
||||
self.assertLess(size, RECORDER_ATTRIBUTES, size)
|
||||
self.assertEqual(set(snapshot), {'screen', 'node', 'title', 'pages', 'tiles'})
|
||||
documented = {'entity', 'name', 'page', 'row', 'column', 'slot', 'size', 'controls', 'display', 'tap', 'action', 'to_page'}
|
||||
for item in snapshot['tiles']:
|
||||
self.assertLessEqual(set(item), documented)
|
||||
self.assertEqual(sum('action' in item for item in snapshot['tiles']), 4)
|
||||
|
||||
|
||||
def largest_tap_action():
|
||||
"""A tap action as large as validate_tap_action allows: eight fields, ACTION_MAX_BYTES on the wire."""
|
||||
data = {f'field_{n}': '' for n in range(8)}
|
||||
while True:
|
||||
for key in data:
|
||||
trial = {**data, key: data[key] + 'v'}
|
||||
act = action_for_screen({'action': 'light.turn_on', 'data': trial})
|
||||
if len(json.dumps(act, separators=(',', ':')).encode()) > ACTION_MAX_BYTES:
|
||||
return validate_tap_action({'action': 'light.turn_on', 'data': data})
|
||||
data = trial
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Saving(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, layout_tiles):
|
||||
ha = test_scaling.fake_ha(firmware='0.2.63')
|
||||
for tile in layout_tiles:
|
||||
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
|
||||
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def test_long_entity_ids_are_refused_when_saved(self):
|
||||
long_ids, short_ids = tiles(MAX_TILES, 80), tiles(MAX_TILES)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, long_ids + short_ids)
|
||||
with self.assertRaisesRegex(ValueError, TOO_LONG):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': long_ids})
|
||||
self.assertNotIn('text.screen', m.layouts)
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': short_ids})
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
|
||||
self.assertTrue(await m.sync_one('text.screen', m.layouts['text.screen']))
|
||||
# A layout an older app stored anyway gets a status, not a retry every pass.
|
||||
stored = validate_layout({'title': 'Hall', 'tiles': long_ids})
|
||||
m.layouts = {'text.screen': stored}
|
||||
m.sent.clear()
|
||||
self.assertFalse(await m.sync_one('text.screen', stored))
|
||||
self.assertIn('too large for one message', m.status['text.screen'])
|
||||
|
||||
async def test_the_largest_valid_layout_is_accepted_and_a_huge_request_is_refused_plainly(self):
|
||||
# 70-character ids are about the longest 48 tiles can have in one message.
|
||||
action = largest_tap_action()
|
||||
layout_tiles = [{'entity': entity(i, 70), 'name': 'é' * 40, 'slot': i,
|
||||
'options': {'tap': 'action', 'action': action, 'background': 'purple', 'icon': 'lightbulb', 'size': 'single',
|
||||
'inline': 'none', 'display': 'standard'}} for i in range(MAX_TILES)]
|
||||
body = {'title': 'T' * 96, 'tiles': layout_tiles}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, layout_tiles)
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
headers = {'X-Screen-CSRF': (await (await client.get('/api/inventory?light=1')).json())['csrf']}
|
||||
raw = json.dumps(body).encode()
|
||||
self.assertGreater(len(raw), 48 * 1024, 'far past the old 16 KB')
|
||||
response = await client.put('/api/screens/text.screen', data=raw, headers={**headers, 'Content-Type': 'application/json'})
|
||||
self.assertEqual((response.status, await response.json()), (200, {'saved': True}))
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
|
||||
encode(m.layout_message('text.screen', m.layouts['text.screen'], m.screen('text.screen')))
|
||||
huge = json.dumps({'title': 'Hall', 'tiles': [], 'filler': 'x' * (128 * 1024)}).encode()
|
||||
response = await client.put('/api/screens/text.screen', data=huge, headers={**headers, 'Content-Type': 'application/json'})
|
||||
self.assertEqual((response.status, await response.json()), (413, {'error': 'That request is too large.'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -22,6 +22,7 @@ from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
|
||||
setting_action, setting_entities, setting_from_state, validate_settings)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_portal
|
||||
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
|
||||
validate_settings)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Stopping the app (app 0.2.78).
|
||||
|
||||
`init: false` came from the first add-on, whose image was Home Assistant's s6 base with its own init. The ESPHome base
|
||||
image has none, so python3 ran as process 1: it ignored SIGTERM (every stop waited ten seconds for SIGKILL) and never
|
||||
reaped a killed build. The app now gets Home Assistant's default init (tini), Docker Compose `init: true`, and the
|
||||
server ends on SIGTERM through its own cleanup, which also stops a running build.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / 'screen_manager/app'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
# The real server.py as `python3 server.py` runs it, with every port the OS's choice instead of 8099 and 8098.
|
||||
LAUNCH = '''
|
||||
import runpy, sys
|
||||
from aiohttp import web
|
||||
class Site(web.TCPSite):
|
||||
def __init__(self, runner, host=None, port=None, **kwargs):
|
||||
super().__init__(runner, '127.0.0.1', 0, **kwargs)
|
||||
web.TCPSite = Site
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
runpy.run_path(sys.argv[1] + '/server.py', run_name='__main__')
|
||||
'''
|
||||
|
||||
|
||||
class Configuration(unittest.TestCase):
|
||||
def test_the_app_and_compose_run_an_init(self):
|
||||
config = (ROOT / 'screen_manager/config.yaml').read_text()
|
||||
self.assertNotRegex(config, r'(?m)^init:', "Home Assistant's default (init: true) gives the app tini as process 1")
|
||||
self.assertRegex((ROOT / 'docker/compose.yaml').read_text(), r'(?m)^ init: true$')
|
||||
self.assertIn('handle_signals=True', (APP / 'server.py').read_text())
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Signal(unittest.TestCase):
|
||||
def test_sigterm_ends_the_app_at_once_and_cleanly(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token = Path(tmp) / 'token'
|
||||
token.write_text('not-a-real-token\n')
|
||||
env = {**os.environ, 'SCREEN_DEV': '1', 'HA_TOKEN_FILE': str(token), 'HA_API': 'http://127.0.0.1:9/api',
|
||||
'SCREEN_DATA': tmp, 'ESPHOME_CONFIG': str(Path(tmp) / 'esphome'), 'HA_CONFIG': tmp, 'PYTHONUNBUFFERED': '1'}
|
||||
process = subprocess.Popen([sys.executable, '-c', LAUNCH, str(APP)], env=env, cwd=tmp,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
# Up once it tried Home Assistant (nothing listens on port 9 here).
|
||||
deadline, lines = time.monotonic() + 20, []
|
||||
while time.monotonic() < deadline:
|
||||
line = process.stderr.readline()
|
||||
lines.append(line)
|
||||
if 'Home Assistant temporarily unavailable' in line or not line:
|
||||
break
|
||||
self.assertIn('Home Assistant temporarily unavailable', ''.join(lines), ''.join(lines))
|
||||
started = time.monotonic()
|
||||
process.send_signal(signal.SIGTERM)
|
||||
code = process.wait(timeout=10)
|
||||
self.assertEqual(code, 0, 'a normal stop, not killed by the signal')
|
||||
self.assertLess(time.monotonic() - started, 5)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
|
||||
import tile_icons
|
||||
import test_portal
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_portal
|
||||
import tile_icons
|
||||
from core import discover, packets, state_message, validate_layout
|
||||
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import TILE_BACKGROUNDS, validate_layout
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import validate_layout, validate_settings, packets
|
||||
|
||||
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env bash
|
||||
# The release checks of docs/RELEASING.md step 2 in one command, the same on a laptop and in CI
|
||||
# (.github/workflows/ci.yml, app 0.2.78). Runs from any folder: every path is taken from this repository.
|
||||
#
|
||||
# tools/check.sh the Python tests, every tests/*.cpp, both generators with --check, the editor's
|
||||
# tests, types and build, and whether the editor bundle in Git equals that build
|
||||
# tools/check.sh --firmware compiles both board profiles and applies the CYD's flash budget
|
||||
# tools/check.sh --all both
|
||||
# --baseline BYTES with --firmware: the CYD image of the last release, to print the growth
|
||||
#
|
||||
# Environment:
|
||||
# PYTHON python3 by default; needs aiohttp, PyYAML, Pillow and fontTools (.venv-portal/bin/python has them)
|
||||
# CXX clang++ by default
|
||||
# ESPHOME the ESPHome command, esphome by default ("python -m esphome", or a newer Device Builder's)
|
||||
# ESPHOME_DATA_DIR where the firmware builds go, .esphome/check by default: apart from the bench profiles' own
|
||||
# build folders, so a check build never replaces the firmware.bin of a screen's profile
|
||||
#
|
||||
# Every check prints PASS, WARN or FAIL; a failing check shows the end of its output. Exit status 1 when one failed.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
PYTHON=${PYTHON:-python3}
|
||||
CXX=${CXX:-clang++}
|
||||
read -r -a ESPHOME_CMD <<< "${ESPHOME:-esphome}"
|
||||
|
||||
usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; }
|
||||
|
||||
want_fast=1 want_firmware=0 saw_firmware=0 saw_all=0 baseline=""
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--firmware) saw_firmware=1 ;;
|
||||
--all) saw_all=1 ;;
|
||||
--baseline)
|
||||
baseline=${2:-}
|
||||
[[ $baseline =~ ^[0-9]+$ ]] || { echo "--baseline needs a size in bytes, such as 1655584" >&2; exit 2; }
|
||||
shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1 (see tools/check.sh --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if ((saw_firmware || saw_all)); then want_firmware=1; fi
|
||||
if ((saw_firmware && !saw_all)); then want_fast=0; fi
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/esp-screens-check.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
passed=0 count=0 last_ok=0
|
||||
failed=() warned=()
|
||||
note() { printf '%s' "$*" > "$WORK/note"; } # a short result for the PASS line, such as "17/17"
|
||||
warn() { printf '%s\n' "$*" >> "$WORK/warn"; } # passes, but someone has to look
|
||||
|
||||
# run NAME COMMAND...: one check. Its output goes to a log that is shown when it fails; last_ok says how it went.
|
||||
# The command runs in a subshell, so its cd stays there. set -e doesn't reach into it (it runs as `cmd || ...`), so
|
||||
# every check function stops on its own errors with `|| return 1`.
|
||||
run() {
|
||||
local name=$1 log="$WORK/log-$((count += 1)).txt" start=$SECONDS status=0 detail=""
|
||||
shift
|
||||
rm -f "$WORK/note" "$WORK/warn"
|
||||
printf '%-40s ' "$name"
|
||||
("$@") > "$log" 2>&1 || status=$?
|
||||
[[ -s $WORK/note ]] && detail=" $(cat "$WORK/note")"
|
||||
if ((status != 0)); then
|
||||
printf 'FAIL%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
# The last 200 lines, each cut at 1000 characters: an assertion can quote a whole header.
|
||||
tail -n 200 "$log" | awk '{ if (length($0) > 1000) $0 = substr($0, 1, 1000) " ..."; print " | " $0 }'
|
||||
failed+=("$name")
|
||||
last_ok=0
|
||||
elif [[ -s $WORK/warn ]]; then
|
||||
printf 'WARN%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
sed 's/^/ ! /' "$WORK/warn"
|
||||
warned+=("$name")
|
||||
last_ok=1
|
||||
else
|
||||
printf 'PASS%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
passed=$((passed + 1))
|
||||
last_ok=1
|
||||
fi
|
||||
}
|
||||
|
||||
skip() { printf '%-40s SKIP (%s)\n' "$1" "$2"; }
|
||||
|
||||
# ---- The fast checks ----
|
||||
|
||||
python_packages() {
|
||||
"$PYTHON" - <<'EOF' || return 1
|
||||
import importlib.util, sys
|
||||
wanted = {'aiohttp': 'aiohttp', 'yaml': 'PyYAML', 'PIL': 'Pillow', 'fontTools': 'fonttools'}
|
||||
missing = [package for module, package in wanted.items() if importlib.util.find_spec(module) is None]
|
||||
print(sys.executable, sys.version.split()[0])
|
||||
# Without them the server and camera tests skip themselves, and a skipped test proves nothing.
|
||||
if missing:
|
||||
sys.exit('Missing: ' + ' '.join(missing) + '. pip install them, or run with PYTHON=.venv-portal/bin/python')
|
||||
EOF
|
||||
note "$("$PYTHON" -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || true)"
|
||||
}
|
||||
|
||||
python_tests() {
|
||||
local out="$WORK/python.txt" status=0
|
||||
(cd "$ROOT" && "$PYTHON" -m unittest discover -s tests) > "$out" 2>&1 || status=$?
|
||||
cat "$out"
|
||||
note "$(grep -Eo '^Ran [0-9]+ tests?' "$out" | tail -n 1 | sed 's/^Ran //')$(grep -Eo 'skipped=[0-9]+' "$out" | tail -n 1 | sed 's/^/, /')"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
cpp_tests() {
|
||||
local test name total=0 good=0
|
||||
cd "$ROOT" || return 1
|
||||
for test in tests/*.cpp; do
|
||||
name=$(basename "$test" .cpp)
|
||||
total=$((total + 1))
|
||||
if "$CXX" -std=c++17 -Wall -Wextra -Werror -I. "$test" -o "$WORK/$name" && "$WORK/$name"; then
|
||||
good=$((good + 1))
|
||||
echo "PASS $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
fi
|
||||
done
|
||||
note "$good/$total"
|
||||
((total > 0 && good == total))
|
||||
}
|
||||
|
||||
packages_current() { cd "$ROOT" && "$PYTHON" tools/generate_packages.py --check; }
|
||||
icons_current() { cd "$ROOT" && "$PYTHON" tools/generate_icons.py --check; }
|
||||
editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; }
|
||||
editor_tests() { cd "$ROOT/web" && npm test; }
|
||||
editor_types() { cd "$ROOT/web" && npm run check; }
|
||||
editor_build() { cd "$ROOT/web" && npm run build; }
|
||||
|
||||
# The add-on serves the committed bundle, so it must be what the committed web/src builds to (Vite names every file
|
||||
# after a hash of its content). Compared with Git's index, so a freshly built bundle that is staged passes.
|
||||
editor_bundle() {
|
||||
local static=screen_manager/app/static untracked
|
||||
cd "$ROOT" || return 1
|
||||
git rev-parse --is-inside-work-tree > /dev/null || { echo "Not a Git work tree: can't compare the bundle"; return 1; }
|
||||
untracked=$(git ls-files --others --exclude-standard -- "$static")
|
||||
if git diff --quiet -- "$static" && [[ -z $untracked ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "A fresh build of web/ differs from $static in Git. Commit the new build (it is in place now):"
|
||||
git diff --stat -- "$static"
|
||||
[[ -z $untracked ]] || printf 'new: %s\n' $untracked
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- Firmware ----
|
||||
|
||||
esphome_version() {
|
||||
local pinned running
|
||||
pinned=$(sed -n 's|^FROM ghcr.io/esphome/esphome:||p' "$ROOT/screen_manager/Dockerfile")
|
||||
running=$("${ESPHOME_CMD[@]}" version | sed -n 's/^Version: //p')
|
||||
echo "ESPHome $running (${ESPHOME_CMD[*]}); the add-on ships $pinned"
|
||||
note "$running"
|
||||
[[ -n $running ]] || return 1
|
||||
[[ $running == "$pinned" ]] || warn "The add-on ships ESPHome $pinned: measure the release budget on that one too."
|
||||
}
|
||||
|
||||
# The builds users get come from the YAML core.installation_yaml() writes: the board's package plus the device's own
|
||||
# keys, the Wi-Fi fallback access point and captive_portal. The board profiles carry all of that (with !secret), so a
|
||||
# copy of each profile compiles in a temporary folder with placeholder secrets of the same length as real ones, next
|
||||
# to links to this tree's components and fonts: this commit's code, never GitHub's main, never the real secrets.yaml.
|
||||
prepare_profiles() {
|
||||
local config="$WORK/config" board file
|
||||
mkdir -p "$config" && ln -s "$ROOT/components" "$config/components" && ln -s "$ROOT/fonts" "$config/fonts" || return 1
|
||||
cat > "$config/secrets.yaml" <<'EOF' || return 1
|
||||
# Placeholders for the check builds (tools/check.sh); nothing here is a real key.
|
||||
wifi_ssid: "check-wifi"
|
||||
wifi_password: "check-wifi-password"
|
||||
api_encryption_key: "Y2hlY2stYnVpbGQtcGxhY2Vob2xkZXIta2V5LTMyYnk="
|
||||
ota_password: "check-build-ota-password-0000000"
|
||||
ap_password: "check-ap-passwd0"
|
||||
EOF
|
||||
for board in cyd guition; do
|
||||
file=$([[ $board == cyd ]] && echo home-like-2432s028.yaml || echo guition-4848s040.yaml)
|
||||
cp "$ROOT/$file" "$config/check-$board.yaml" || return 1
|
||||
if ! grep -q '^captive_portal:' "$config/check-$board.yaml" || ! grep -q '^ ap:' "$config/check-$board.yaml"; then
|
||||
echo "$file has no captive_portal: or wifi ap: any more, unlike the YAML users get; the flash figures would read low."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
echo "Check profiles in $config"
|
||||
}
|
||||
|
||||
compile_board() { # compile_board cyd|guition
|
||||
local board=$1
|
||||
cd "$WORK/config" || return 1
|
||||
"${ESPHOME_CMD[@]}" -s DEVICE_NAME "check-$board" -s DEVICE_FRIENDLY_NAME "Check $board" compile "check-$board.yaml" || return 1
|
||||
flash_report "$board"
|
||||
}
|
||||
|
||||
# flash_report BOARD [budget]: the image against its update slot, both read from the build, and on the CYD the growth
|
||||
# against --baseline and the budget of docs/RELEASING.md step 2. Exit 3 means over 90 %: it passes, but the release has
|
||||
# to say why.
|
||||
flash_report() {
|
||||
local board=$1 mode=${2:-} status=0 out="$WORK/flash-$1.txt" against=""
|
||||
[[ $board == cyd ]] && against=$baseline
|
||||
"$PYTHON" - "$ESPHOME_DATA_DIR/build/check-$board" "$mode" "$against" > "$out" 2>&1 <<'EOF' || status=$?
|
||||
import csv, sys
|
||||
from pathlib import Path
|
||||
|
||||
build, mode, baseline = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
|
||||
|
||||
def size(text):
|
||||
text = text.strip().upper()
|
||||
scale = {'K': 1024, 'M': 1024 * 1024}.get(text[-1:], 1)
|
||||
return int(text[:-1] if scale > 1 else text, 0) * scale
|
||||
|
||||
rows = csv.reader(line for line in (build / 'partitions.csv').read_text().splitlines()
|
||||
if line.strip() and not line.lstrip().startswith('#'))
|
||||
slot = next((size(row[4]) for row in ([cell.strip() for cell in row] for row in rows)
|
||||
if len(row) >= 5 and row[1] == 'app' and (row[2] == 'ota_0' or row[0] == 'app0')), None)
|
||||
if not slot:
|
||||
sys.exit(f'No app0/ota_0 partition in {build / "partitions.csv"}')
|
||||
images = sorted(build.glob('.pioenvs/*/firmware.ota.bin')) or sorted(build.glob('.pioenvs/*/firmware.bin')) \
|
||||
or sorted(build.rglob('firmware.ota.bin'))
|
||||
if not images:
|
||||
sys.exit(f'No firmware.ota.bin under {build}')
|
||||
image = images[0].stat().st_size
|
||||
share = image / slot * 100
|
||||
line = f'{image:,} B of {slot:,} B = {share:.1f} % ({slot - image:,} B free)'
|
||||
delta = None
|
||||
if baseline:
|
||||
delta = image - int(baseline)
|
||||
line += f', {delta:+,} B against {int(baseline):,} B'
|
||||
print(line)
|
||||
if mode != 'budget':
|
||||
sys.exit(0)
|
||||
if share > 95:
|
||||
print('Over 95 %: never ship this; it leaves no room for ESPHome upgrades and users\' own overrides.')
|
||||
sys.exit(1)
|
||||
if share > 93:
|
||||
print('93-95 %: only fixes ship.')
|
||||
sys.exit(3)
|
||||
if share > 90:
|
||||
print('90-93 %: tight. The release states its flash delta; more than 8 KB needs a matching saving or Max\'s OK.'
|
||||
+ (f' This one grows {delta:,} B.' if delta is not None and delta > 8192 else ''))
|
||||
sys.exit(3)
|
||||
EOF
|
||||
cat "$out"
|
||||
note "$(head -n 1 "$out")"
|
||||
if ((status == 3)); then
|
||||
warn "$(tail -n +2 "$out")"
|
||||
return 0
|
||||
fi
|
||||
return "$status"
|
||||
}
|
||||
|
||||
cyd_budget() { flash_report cyd budget; }
|
||||
|
||||
# ---- Run ----
|
||||
|
||||
echo "ESP Screens checks in $ROOT"
|
||||
if ((want_fast)); then
|
||||
run "Python packages" python_packages
|
||||
run "Python tests" python_tests
|
||||
run "C++ tests" cpp_tests
|
||||
run "Packages match the profiles" packages_current
|
||||
run "Icons match tile_icons.py" icons_current
|
||||
run "Editor: npm ci" editor_install
|
||||
if ((last_ok)); then
|
||||
run "Editor: tests (Vitest)" editor_tests
|
||||
run "Editor: types (vue-tsc)" editor_types
|
||||
run "Editor: build" editor_build
|
||||
if ((last_ok)); then run "Editor: bundle in Git is fresh" editor_bundle; else skip "Editor: bundle in Git is fresh" "no build"; fi
|
||||
else
|
||||
skip "Editor: tests, types, build" "npm ci failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((want_firmware)); then
|
||||
export ESPHOME_DATA_DIR=${ESPHOME_DATA_DIR:-$ROOT/.esphome/check}
|
||||
run "ESPHome" esphome_version
|
||||
if ((last_ok)); then run "Check profiles" prepare_profiles; fi
|
||||
if ((last_ok)); then
|
||||
# One after the other: parallel ESP-IDF builds race on PlatformIO's shared cache.
|
||||
run "Firmware: CYD" compile_board cyd
|
||||
if ((last_ok)); then run "CYD flash budget" cyd_budget; else skip "CYD flash budget" "no CYD build"; fi
|
||||
run "Firmware: Guition" compile_board guition
|
||||
else
|
||||
skip "Firmware builds" "ESPHome or the check profiles are missing"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "$passed passed, ${#warned[@]} warned, ${#failed[@]} failed"
|
||||
((${#warned[@]} == 0)) || printf 'WARN: %s\n' "${warned[@]}"
|
||||
if ((${#failed[@]})); then
|
||||
printf 'FAIL: %s\n' "${failed[@]}"
|
||||
exit 1
|
||||
fi
|
||||
+4
-1
@@ -3,9 +3,12 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"runtime": { "name": "node", "version": "^20.19.0 || >=22.12.0", "onFail": "error" }
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "rm -rf ../screen_manager/app/static/assets && vite build",
|
||||
"build": "vue-tsc --noEmit && rm -rf ../screen_manager/app/static/assets && vite build",
|
||||
"check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
+4
-1
@@ -16,9 +16,12 @@ export async function api(path: string, options: RequestInit = {}): Promise<Resp
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Any answer with a JSON {"error": "..."} says what went wrong, such as Home Assistant refusing an action (400) or
|
||||
// not answering in time (503, app 0.2.78); anything else, like an error page of the ingress proxy, gets this one.
|
||||
let message = "That didn't work. Refresh the page and try again.";
|
||||
try {
|
||||
message = (await response.json()).error || message;
|
||||
const error = (await response.json())?.error;
|
||||
if (typeof error === "string" && error.trim()) message = error;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { canAlert, copyText, go, sendTestAlert, state } from "../store";
|
||||
import { canAlert, copyText, firmwareVersion, go, sendTestAlert, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them.
|
||||
@@ -141,8 +141,8 @@ const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"],
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
|
||||
<div class="alert-screen-head">
|
||||
<strong>{{ screen.name }}</strong>
|
||||
<span class="chip" :class="versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
<span class="chip" :class="versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
|
||||
@@ -17,9 +17,8 @@ const progress = computed(() => (running.value ? updateProgress(running.value) :
|
||||
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
|
||||
// What the current firmware brings: the changelog sections that mention it.
|
||||
const targetNotes = computed(() => {
|
||||
const sections = (u.value as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
|
||||
const target = u.value?.target;
|
||||
if (!sections || !target) return [];
|
||||
const sections = state.inventory.changelog, target = u.value?.target;
|
||||
if (!Array.isArray(sections) || !target) return [];
|
||||
return sections.filter((s) => s.firmware === target).flatMap((s) => s.lines).slice(0, 10);
|
||||
});
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
|
||||
@@ -43,7 +43,7 @@ watch(() => props.entity, load);
|
||||
<div id="inspection-summary">
|
||||
<p v-if="error" class="hint warn">{{ error }}</p>
|
||||
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
|
||||
<article v-for="tile in summary || []" :key="tile.entity" class="inspection-tile">
|
||||
<article v-for="(tile, i) in summary || []" :key="i" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
|
||||
import { addTile, automaticIcon, isGuition, repeatable, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
@@ -15,6 +15,8 @@ const FILTERS: [string, string][] = [
|
||||
];
|
||||
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
|
||||
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
|
||||
// On the screen and not to be added again; a page tile can be, when the firmware takes several (0.2.65).
|
||||
const placed = (id: string) => chosen.value.has(id) && !repeatable(id);
|
||||
const rooms = computed(() => [...new Set(state.inventory.entities.map((e) => e.area).filter((a): a is string => Boolean(a)))].sort((a, b) => a.localeCompare(b)));
|
||||
const matches = computed(() => {
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter, room = state.room;
|
||||
@@ -24,7 +26,7 @@ const matches = computed(() => {
|
||||
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
|
||||
(!room || e.area === room) &&
|
||||
(!state.hidePlaced || !chosen.value.has(e.id)) &&
|
||||
(!state.hidePlaced || !placed(e.id)) &&
|
||||
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
|
||||
@@ -58,13 +60,13 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
|
||||
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
:disabled="placed(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
<span class="av mdi" :class="tone(entity)" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ entity.name }}</b>
|
||||
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
|
||||
</span>
|
||||
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
|
||||
<span class="add" :class="{ done: placed(entity.id) }">{{ placed(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
|
||||
@@ -52,6 +52,7 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" aria-label="Unsaved changes" title="Unsaved changes"></span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
|
||||
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import { entityName, isSelected, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -21,7 +21,7 @@ const controls = computed(() => effectiveControls(props.tile, state.inventory));
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const cp = computed(() => state.inventory.icons?.controls || {});
|
||||
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
|
||||
const chosen = computed(() => state.selectedTile === props.tile.entity && state.inspector?.kind === "tile");
|
||||
const chosen = computed(() => isSelected(props.tile) && state.inspector?.kind === "tile");
|
||||
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
|
||||
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
|
||||
const now = computed(() => new Date(state.now));
|
||||
@@ -77,7 +77,7 @@ const setpoint = computed(() => {
|
||||
});
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile); return; }
|
||||
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
@@ -93,7 +93,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<div class="tile" :class="{ wide, full, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
|
||||
:style="background && !bare ? { backgroundColor: background } : undefined"
|
||||
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile)" @keydown="live && onKey($event)">
|
||||
<template v-if="display === 'analog'">
|
||||
<svg class="clockface" viewBox="0 0 60 60" aria-hidden="true">
|
||||
<circle cx="30" cy="30" r="27" fill="#fff" stroke="#c9ccd1" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
||||
import { computed } from "vue";
|
||||
import { domainInfo, MAX_PAGES, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { computed, toRaw } from "vue";
|
||||
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import ActionPicker from "./ActionPicker.vue";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
@@ -14,9 +14,31 @@ const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const name = computed(() => entityName(props.tile.entity));
|
||||
// A navigation tile (screen.page_<n>): the page it opens, its size, icon and colour; nothing else applies.
|
||||
const goesTo = computed(() => pageTarget(props.tile.entity));
|
||||
const pages = Array.from({ length: MAX_PAGES }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
// Pages counted from 1. "Goes to page" offers the pages the screen has and the empty one after them, where a sub-page
|
||||
// starts (app 0.2.78), and keeps a target beyond those so the choice stays visible.
|
||||
const pageTotal = computed(() => (state.layout ? pageCount(entriesOf(state.layout), state.layout.pages) : 1));
|
||||
const pageHere = computed(() => pageOf(props.tile.slot) + 1);
|
||||
const emptyPage = (n: number) => !state.layout?.tiles.some((t) => pageOf(t.slot) === n - 1);
|
||||
const pages = computed(() => {
|
||||
const list = Array.from({ length: Math.min(MAX_PAGES, pageTotal.value + 1) }, (_, i) => i + 1);
|
||||
if (goesTo.value > list.length) list.push(goesTo.value);
|
||||
return list.map((n) => [n, emptyPage(n) ? `${n} (empty)` : String(n)] as [number, string]);
|
||||
});
|
||||
const goesToHint = computed(() => !fullPage.value
|
||||
? { text: "Navigation tiles need firmware 0.2.62: press Update on the screen first.", warn: false }
|
||||
: goesTo.value > pageTotal.value
|
||||
? { text: `This screen has no page ${goesTo.value}, so a tap opens its last page. Choose another page.`, warn: true }
|
||||
: { text: "A tap on the tile opens that page.", warn: false });
|
||||
// Moving the tile without a drag (app 0.2.78): another page, or a new one after the last. A tile alone on the last page
|
||||
// gets no "New page", which would only leave an empty page behind; with nowhere to go the row stays hidden.
|
||||
const alone = computed(() => !state.layout?.tiles.some((t) => toRaw(t) !== toRaw(props.tile) && pageOf(t.slot) === pageHere.value - 1));
|
||||
const onPage = computed(() => {
|
||||
const list = Array.from({ length: pageTotal.value }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, "New page"]);
|
||||
return list;
|
||||
});
|
||||
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
|
||||
const sizeHint = computed(() => goesTo.value ? "" : supports(0, 2, 62)
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value
|
||||
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
|
||||
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
@@ -100,7 +122,7 @@ function inspect() {
|
||||
<div v-if="goesTo" class="f">
|
||||
<span class="f-label">Goes to page</span>
|
||||
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
|
||||
<small>{{ supports(0, 2, 62) ? "A tap on the tile opens that page." : "Navigation tiles need firmware 0.2.62: press Update on the screen first." }}</small>
|
||||
<small :class="{ warn: goesToHint.warn }">{{ goesToHint.text }}</small>
|
||||
</div>
|
||||
<div v-else class="f">
|
||||
<span class="f-label">Display</span>
|
||||
@@ -112,6 +134,10 @@ function inspect() {
|
||||
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
<small v-if="sizeHint">{{ sizeHint }}</small>
|
||||
</div>
|
||||
<div v-if="onPage.length > 1" class="f">
|
||||
<span class="f-label">Page</span>
|
||||
<Segmented :choices="onPage" :value="pageHere" @pick="(v) => moveTileToPage(tile, Number(v) - 1)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
|
||||
+38
-8
@@ -66,19 +66,47 @@ function beginDrag(e: PointerEvent) {
|
||||
try { drag.element.releasePointerCapture(drag.pointerId!); } catch {}
|
||||
try { document.documentElement.setPointerCapture(drag.pointerId!); } catch {}
|
||||
document.addEventListener("touchmove", blockScroll, { passive: false });
|
||||
// Near the edges of the canvas the pages scroll along, so every page can be reached.
|
||||
// Near the edges the pages scroll along, so every page can be reached.
|
||||
drag.scroller = window.setInterval(() => {
|
||||
if (!drag.last) return;
|
||||
const canvas = document.querySelector<HTMLElement>(".canvas");
|
||||
if (!canvas) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dx = drag.last.x < r.left + 60 ? -12 : drag.last.x > r.right - 60 ? 12 : 0;
|
||||
const dy = drag.last.y < r.top + 60 ? -12 : drag.last.y > r.bottom - 60 ? 12 : 0;
|
||||
if (dx || dy) { canvas.scrollBy(dx, dy); setTarget(slotAt(drag.last.x, drag.last.y)); }
|
||||
const { x, y } = dragScrollers(document);
|
||||
const dx = x ? edgeStep(drag.last.x, visibleSpan(x, "x")) : 0;
|
||||
const dy = y ? edgeStep(drag.last.y, visibleSpan(y, "y")) : 0;
|
||||
if (dx) x!.scrollBy(dx, 0);
|
||||
if (dy) y!.scrollBy(0, dy);
|
||||
if (dx || dy) setTarget(slotAt(drag.last.x, drag.last.y));
|
||||
}, 16);
|
||||
setTarget(-1);
|
||||
moveDrag(e);
|
||||
}
|
||||
// True when the element scrolls along that axis: more content than room, and an overflow that lets it scroll.
|
||||
export function scrollsAlong(element: Element | null, axis: "x" | "y") {
|
||||
if (!element) return false;
|
||||
const more = axis === "x" ? element.scrollWidth > element.clientWidth : element.scrollHeight > element.clientHeight;
|
||||
const overflow = getComputedStyle(element)[axis === "x" ? "overflowX" : "overflowY"];
|
||||
return more && (overflow === "auto" || overflow === "scroll");
|
||||
}
|
||||
// What a drag near an edge scrolls, per axis (app 0.2.78). A wide window scrolls the canvas both ways. At 960 px and
|
||||
// narrower (a phone, also in the Home Assistant app) the canvas grows with its content: the row of pages scrolls
|
||||
// sideways and the page itself up and down, so a tile can still reach page 2 and beyond.
|
||||
export function dragScrollers(doc: Document): { x: Element | null; y: Element | null } {
|
||||
const pages = doc.querySelector(".pages"), canvas = doc.querySelector(".canvas");
|
||||
return {
|
||||
x: scrollsAlong(pages, "x") ? pages : canvas,
|
||||
y: scrollsAlong(canvas, "y") ? canvas : doc.scrollingElement,
|
||||
};
|
||||
}
|
||||
// The part of a scroller on screen along one axis; the page itself is the whole window.
|
||||
function visibleSpan(element: Element, axis: "x" | "y"): [number, number] {
|
||||
const view = axis === "x" ? window.innerWidth : window.innerHeight;
|
||||
if (element === element.ownerDocument.scrollingElement) return [0, view];
|
||||
const r = element.getBoundingClientRect();
|
||||
return axis === "x" ? [Math.max(r.left, 0), Math.min(r.right, view)] : [Math.max(r.top, 0), Math.min(r.bottom, view)];
|
||||
}
|
||||
// Within 60 px of either end the scroller moves 12 px per step towards that end.
|
||||
export function edgeStep(position: number, [start, end]: [number, number]) {
|
||||
return position < start + 60 ? -12 : position > end - 60 ? 12 : 0;
|
||||
}
|
||||
function blockScroll(e: TouchEvent) { if (state.drag.active) e.preventDefault(); }
|
||||
function finishDrag(e: PointerEvent) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
|
||||
function moveDrag(e: PointerEvent) {
|
||||
@@ -122,11 +150,13 @@ function endDrag(drop: boolean) {
|
||||
state.drag.moving = null;
|
||||
if (drop && preview && moving && state.layout) {
|
||||
const before = state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
|
||||
// By the tile itself, not its entity: a screen can have several tiles that go to the same page (firmware 0.2.65).
|
||||
const added = !state.layout.tiles.includes(moving);
|
||||
for (const { tile, slot } of preview) { tile.slot = slot; if (!state.layout.tiles.includes(tile)) state.layout.tiles.push(tile); }
|
||||
state.layout.tiles.sort((a, b) => a.slot - b.slot);
|
||||
if (state.layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before) markDirty();
|
||||
// Placing goes through the same rules as a click; a new tile also asks for its capabilities.
|
||||
if (!before.includes(`${moving.entity}@`)) placeTile(moving, moving.slot);
|
||||
if (added) placeTile(moving, moving.slot);
|
||||
}
|
||||
}
|
||||
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
|
||||
@@ -130,18 +130,19 @@ export function controlsLabel(tile: Tile, inventory: Inventory) {
|
||||
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
|
||||
}
|
||||
|
||||
export const parseVersion = (v: string | undefined) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
|
||||
export function versionAtLeast(version: string | undefined, minimum: string) {
|
||||
export const parseVersion = (v: string | undefined | null) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
|
||||
export function versionAtLeast(version: string | undefined | null, minimum: string) {
|
||||
const [a, b] = [parseVersion(version), parseVersion(minimum)];
|
||||
if (a.length !== 3 || b.length !== 3) return false;
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
||||
return true;
|
||||
}
|
||||
export const supportsFirmware = (firmware: string | undefined, major: number, minor: number, patch: number) =>
|
||||
export const supportsFirmware = (firmware: string | undefined | null, major: number, minor: number, patch: number) =>
|
||||
versionAtLeast(firmware, `${major}.${minor}.${patch}`);
|
||||
// Firmware 0.2.62 holds one tile per slot (48); 0.2.7 twenty; older firmware ten.
|
||||
// Firmware 0.2.62 holds one tile per slot (48); 0.2.7 twenty; older firmware ten. The add-on tells the editor per
|
||||
// screen (tile_limit, app 0.2.78); this rule stays for a screen entry without it.
|
||||
export const MAX_TILES = MAX_SLOTS;
|
||||
export function tileLimit(firmware: string | undefined) {
|
||||
export function tileLimit(firmware: string | undefined | null) {
|
||||
if (parseVersion(firmware).length !== 3) return 10;
|
||||
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
|
||||
}
|
||||
|
||||
+77
-23
@@ -1,6 +1,6 @@
|
||||
// One reactive state for the whole editor. The Python API (server.py) is unchanged: this file is the
|
||||
// former app.js state and its calls, with the DOM work moved into the components.
|
||||
import { computed, reactive } from "vue";
|
||||
import { computed, reactive, toRaw } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import {
|
||||
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./model/layout";
|
||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||
import { versionAtLeast } from "./model/layout";
|
||||
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
|
||||
import type { Capability, ChangelogSection, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
|
||||
|
||||
export type Inspector =
|
||||
| { kind: "tile" }
|
||||
@@ -29,7 +29,8 @@ export const state = reactive({
|
||||
busy: false,
|
||||
saved: 0,
|
||||
tab: "layout" as "layout" | "settings",
|
||||
selectedTile: null as string | null,
|
||||
// The tile itself, not its entity: several tiles can go to the same page (firmware 0.2.65).
|
||||
selectedTile: null as Tile | null,
|
||||
inspector: null as Inspector | null,
|
||||
iconPickerOpen: false,
|
||||
actionPickerOpen: false,
|
||||
@@ -61,13 +62,35 @@ export const state = reactive({
|
||||
});
|
||||
|
||||
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
|
||||
export const firmwareOf = computed(() => currentScreen.value?.firmware || "");
|
||||
// The firmware version a screen's features go by, as the add-on works it out (firmware_known, app 0.2.78; null when it
|
||||
// can't tell). A screen entry without the field goes by the firmware text, as before.
|
||||
export const firmwareVersion = (screen: Screen | undefined) =>
|
||||
(screen && "firmware_known" in screen ? screen.firmware_known : screen?.firmware) || "";
|
||||
export const firmwareOf = computed(() => firmwareVersion(currentScreen.value));
|
||||
export const supports = (major: number, minor: number, patch: number) => supportsVersion(firmwareOf.value, major, minor, patch);
|
||||
export const tileLimit = computed(() => limitFor(firmwareOf.value));
|
||||
// What the screen holds and draws, as the add-on says (app 0.2.78), so a screen whose version Home Assistant can't
|
||||
// report for a moment keeps its 48 tiles instead of dropping to ten, and a copied or imported layout isn't cut to ten.
|
||||
export const tileLimit = computed(() => {
|
||||
const limit = currentScreen.value?.tile_limit;
|
||||
return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? limit : limitFor(firmwareOf.value);
|
||||
});
|
||||
export const fullPage = computed(() => {
|
||||
const full = currentScreen.value?.full_page;
|
||||
return typeof full === "boolean" ? full : supports(0, 2, 62);
|
||||
});
|
||||
// Several tiles that go to the same page, such as a way back to page 1 on every sub-page (firmware 0.2.65); every
|
||||
// other entity stays once per screen.
|
||||
export const pageTilesRepeat = computed(() => {
|
||||
const repeat = currentScreen.value?.page_tiles_repeat;
|
||||
return typeof repeat === "boolean" ? repeat : supports(0, 2, 65);
|
||||
});
|
||||
export const repeatable = (id: string) => pageTilesRepeat.value && pageTarget(id) > 0;
|
||||
export const isGuition = computed(() => currentScreen.value?.board === "guition");
|
||||
export const barMetrics = computed(() => BAR_METRICS[isGuition.value ? "guition" : "cyd"]);
|
||||
export const currentTile = computed<Tile | undefined>(() =>
|
||||
state.selectedTile && state.layout ? state.layout.tiles.find((t) => t.entity === state.selectedTile) : undefined);
|
||||
state.selectedTile && state.layout?.tiles.includes(state.selectedTile) ? state.selectedTile : undefined);
|
||||
// The reactive copy and the plain object are the same tile.
|
||||
export const isSelected = (tile: Tile) => Boolean(state.selectedTile) && toRaw(state.selectedTile) === toRaw(tile);
|
||||
|
||||
// ---- Toasts ----
|
||||
let toastTimer = 0;
|
||||
@@ -191,11 +214,23 @@ export function liveOf(entity: string): Live | null {
|
||||
}
|
||||
|
||||
// ---- Selecting a screen and editing its layout ----
|
||||
// Every edit counts, so a save only clears the edits it sent (app 0.2.78).
|
||||
let edits = 0;
|
||||
export function markDirty() {
|
||||
state.dirty = true;
|
||||
state.saved = 0;
|
||||
edits++;
|
||||
}
|
||||
export function select(id: string | null) {
|
||||
// The open screen again, the way back from Firmware & USB, Alerts or Settings: its unsaved edits stay (app 0.2.78).
|
||||
// Without edits it reads the stored layout again, which picks up what Claude or another tab changed meanwhile.
|
||||
if (id === state.selected && state.layout && state.dirty) {
|
||||
closeInspector();
|
||||
state.tab = "layout";
|
||||
state.menuOpen = false;
|
||||
go("");
|
||||
return;
|
||||
}
|
||||
if (id !== state.selected && state.dirty && !confirm("You have unsaved changes. Open a different screen anyway?")) return;
|
||||
if (id !== state.selected) {
|
||||
flushSettings();
|
||||
@@ -244,11 +279,11 @@ export function placeTile(tile: Tile, target: number) {
|
||||
// A click in the picker: the marked empty cell, else the first free cell.
|
||||
export function addTile(id: string) {
|
||||
const layout = state.layout;
|
||||
if (!layout || layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit.value) return;
|
||||
if (!layout || (!repeatable(id) && layout.tiles.some((t) => t.entity === id)) || layout.tiles.length >= tileLimit.value) return;
|
||||
const tile = newTile(id);
|
||||
const slot = state.insertAt >= 0 ? state.insertAt : firstFree(occupied(entriesOf(layout)), sizeOf(tile));
|
||||
state.insertAt = -1;
|
||||
if (slot >= 0 && placeTile(tile, slot)) openTile(id);
|
||||
if (slot >= 0 && placeTile(tile, slot)) openTile(tile);
|
||||
}
|
||||
export function removeTile(tile: Tile) {
|
||||
const layout = state.layout;
|
||||
@@ -256,7 +291,7 @@ export function removeTile(tile: Tile) {
|
||||
const index = layout.tiles.indexOf(tile);
|
||||
if (index < 0) return;
|
||||
layout.tiles.splice(index, 1);
|
||||
if (state.selectedTile === tile.entity) closeInspector();
|
||||
if (isSelected(tile)) closeInspector();
|
||||
markDirty();
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
toast(`${tile.name || entityName(tile.entity)} removed`, { label: "Undo", run: () => placeTile(tile, tile.slot) });
|
||||
@@ -275,6 +310,17 @@ export function removePage(page: number) {
|
||||
layout.pages = Math.max(1, pageCount(entriesOf(layout), layout.pages) - 1);
|
||||
markDirty();
|
||||
}
|
||||
// Moving a tile without dragging it (app 0.2.78), for a finger on a phone and for anyone who can't drag: the first
|
||||
// free cell of that page, else its first cell, where the tile in the way swaps places as it does for a drop or an
|
||||
// arrow key. `page` counts from 0; the page after the last one starts a new page.
|
||||
export function moveTileToPage(tile: Tile, page: number) {
|
||||
const layout = state.layout;
|
||||
if (!layout || !Number.isInteger(page) || page < 0 || page >= MAX_PAGES || page === pageOf(tile.slot)) return false;
|
||||
const slot = firstFree(occupied(entriesOf(layout).filter((e) => e.tile !== tile)), sizeOf(tile), page * SLOTS_PER_PAGE);
|
||||
const moved = placeTile(tile, slot >= 0 && pageOf(slot) === page ? slot : page * SLOTS_PER_PAGE);
|
||||
if (!moved) toast(`There is no room for this tile on page ${page + 1}.`);
|
||||
return moved;
|
||||
}
|
||||
export function pagesShown() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return 1;
|
||||
@@ -326,23 +372,24 @@ export function setTileOption(tile: Tile, key: string, value: unknown) {
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
}
|
||||
// A navigation tile goes to another page: its entity changes (screen.page_<n>), one tile per page it goes to.
|
||||
// A navigation tile goes to another page: its entity changes (screen.page_<n>). One tile per page it goes to, unless
|
||||
// the firmware takes several (0.2.65). The page after the last one becomes a new, empty page to fill (app 0.2.78).
|
||||
export function retargetPageTile(tile: Tile, page: number) {
|
||||
const entity = `screen.page_${page}`;
|
||||
if (!state.layout || entity === tile.entity || !pageTarget(entity)) return false;
|
||||
if (state.layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
|
||||
const entity = `screen.page_${page}`, layout = state.layout;
|
||||
if (!layout || entity === tile.entity || !pageTarget(entity)) return false;
|
||||
if (!repeatable(entity) && layout.tiles.some((t) => t.entity === entity)) { toast(`This screen already has a tile that goes to page ${page}.`); return false; }
|
||||
tile.entity = entity;
|
||||
if (state.selectedTile) state.selectedTile = entity;
|
||||
layout.pages = Math.max(pageCount(entriesOf(layout), layout.pages), page);
|
||||
markDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Inspector (the drawer) ----
|
||||
export function openTile(entity: string) {
|
||||
if (state.selectedTile !== entity) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
|
||||
state.selectedTile = entity;
|
||||
export function openTile(tile: Tile) {
|
||||
if (!isSelected(tile)) { state.iconPickerOpen = false; state.actionPickerOpen = false; state.actionSearch = ""; }
|
||||
state.selectedTile = tile;
|
||||
state.inspector = { kind: "tile" };
|
||||
loadCapabilities([entity]);
|
||||
loadCapabilities([tile.entity]);
|
||||
}
|
||||
export function openBar(index: number) {
|
||||
if (!(state.inspector?.kind === "bar" && state.inspector.index === index)) state.iconPickerOpen = false;
|
||||
@@ -362,13 +409,19 @@ export function closeInspector() {
|
||||
export async function save() {
|
||||
if (state.busy || !state.layout || !state.selected) return;
|
||||
state.busy = true;
|
||||
// The layout as it leaves: a drag, the drawer or Add still work while the request is on its way (the add-on's check
|
||||
// can take seconds after Copy or Import), and those changes aren't in it (app 0.2.78).
|
||||
const sent = edits, screen = state.selected;
|
||||
try {
|
||||
// Screen settings apply on their own (flushSettings); the stored ones stay as they are.
|
||||
const { settings: _settings, ...tiles } = state.layout;
|
||||
await send(`screens/${encodeURIComponent(state.selected)}`, "PUT", tiles);
|
||||
if (state.selected !== screen) toast(`Saved. ${state.inventory.screens.find((s) => s.id === screen)?.name || "The screen"} is being updated.`);
|
||||
else if (edits === sent) {
|
||||
state.dirty = false;
|
||||
state.saved = Date.now();
|
||||
toast("Saved. Your screen is being updated.");
|
||||
} else toast("Saved. Your newest change isn't sent yet: press Save & send again.");
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
@@ -379,7 +432,7 @@ export async function save() {
|
||||
|
||||
// ---- Identify and the test alert (app 0.2.73): a screen's own show_alert action ----
|
||||
export const canAlert = (screen: Screen | undefined) =>
|
||||
Boolean(screen && screen.alert_action && versionAtLeast(screen.firmware, state.inventory.alerts?.min_firmware || "0.2.31"));
|
||||
Boolean(screen && screen.alert_action && versionAtLeast(firmwareVersion(screen), state.inventory.alerts?.min_firmware || "0.2.31"));
|
||||
export async function identify(screen: Screen) {
|
||||
try {
|
||||
await send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST");
|
||||
@@ -402,7 +455,7 @@ function adopt(source: Partial<Layout>, what: string) {
|
||||
.map((t) => ({ entity: t.entity, name: typeof t.name === "string" ? t.name : "", slot: Number.isInteger(t.slot) ? t.slot : -1,
|
||||
...(t.options && typeof t.options === "object" ? { options: { ...t.options } } : {}) }) as Tile);
|
||||
const seen = new Set<string>();
|
||||
const unique = tiles.filter((t) => !seen.has(t.entity) && seen.add(t.entity));
|
||||
const unique = tiles.filter((t) => repeatable(t.entity) || (!seen.has(t.entity) && seen.add(t.entity)));
|
||||
const kept = unique.slice(0, tileLimit.value);
|
||||
layout.tiles = kept;
|
||||
if (source.header && Array.isArray(source.header.items)) layout.header = { items: source.header.items.map((i) => ({ ...i })) };
|
||||
@@ -449,11 +502,12 @@ export function importLayout(text: string) {
|
||||
}
|
||||
|
||||
// ---- Updates with content (app 0.2.73): what a screen gets, and how far its update is ----
|
||||
// The changelog comes with the full inventory only (app 0.2.78): the live payload goes out every few seconds.
|
||||
export function whatsNew(screen: Screen): string[] {
|
||||
const target = state.inventory.updates?.target;
|
||||
const sections = (state.inventory.updates as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
|
||||
if (!sections || !target) return [];
|
||||
const since = screen.firmware;
|
||||
const sections: ChangelogSection[] | undefined = state.inventory.changelog;
|
||||
if (!Array.isArray(sections) || !target) return [];
|
||||
const since = firmwareVersion(screen);
|
||||
const lines: string[] = [];
|
||||
for (const section of sections) {
|
||||
if (versionAtLeast(section.firmware, target) && section.firmware !== target) continue;
|
||||
|
||||
@@ -54,6 +54,8 @@ a:hover { text-decoration: underline; }
|
||||
.nav-item[aria-current="true"] { background: var(--seg); color: var(--ink); font-weight: 500; }
|
||||
.nav-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; }
|
||||
.nav-item .dot.off { background: var(--line-strong); }
|
||||
/* The open screen has edits that aren't saved yet: the colour of the "Unsaved changes" chip. */
|
||||
.nav-item .unsaved { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); flex: none; }
|
||||
.nav-item .txt { display: grid; min-width: 0; flex: 1; }
|
||||
.nav-item .txt small { font-size: 11px; color: var(--muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item .mdi { font-size: 16px; width: 18px; text-align: center; color: var(--muted); }
|
||||
@@ -140,7 +142,9 @@ a:hover { text-decoration: underline; }
|
||||
.bar-wrap.selected { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.bar-wrap svg { display: block; width: 100%; height: auto; }
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(0, 1fr); gap: 8px; min-height: 0; }
|
||||
.tile { position: relative; background: #fff; border: 1px solid #dcdcdc; border-radius: 12px; padding: 8px 9px; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; min-height: 0; cursor: pointer; text-align: left; overflow: hidden; color: #1b1b1b; touch-action: pan-y; }
|
||||
/* manipulation, not pan-y (app 0.2.78): on a phone the row of pages scrolls sideways, also when the swipe starts on a
|
||||
tile. The hold before a drag and the blocked touchmove during one still keep the page from scrolling. */
|
||||
.tile { position: relative; background: #fff; border: 1px solid #dcdcdc; border-radius: 12px; padding: 8px 9px; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; min-height: 0; cursor: pointer; text-align: left; overflow: hidden; color: #1b1b1b; touch-action: manipulation; }
|
||||
.tile:hover { border-color: #b9c4d3; }
|
||||
.tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.tile.wide { grid-column: span 2; flex-direction: row; align-items: center; gap: 8px; }
|
||||
@@ -201,7 +205,8 @@ a:hover { text-decoration: underline; }
|
||||
.filters button[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
|
||||
.filters .domain-icon { width: 16px; height: 16px; font-size: 11px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.lib-list { overflow: auto; padding: 8px; display: grid; gap: 2px; align-content: start; flex: 1; }
|
||||
.ent { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 9px; width: 100%; text-align: left; cursor: grab; touch-action: pan-y; }
|
||||
/* manipulation like .tile: a finger scrolls either way from here, a hold starts a drag (app 0.2.78). */
|
||||
.ent { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 9px; width: 100%; text-align: left; cursor: grab; touch-action: manipulation; }
|
||||
.ent:hover:not(:disabled) { background: var(--surface-2); }
|
||||
.ent:disabled { opacity: 0.55; cursor: default; }
|
||||
.ent .av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex: none; }
|
||||
|
||||
@@ -29,7 +29,11 @@ export type Screen = {
|
||||
id: string; name: string; online: boolean; area?: string; firmware?: string; board?: string;
|
||||
layout: Layout; update?: UpdateInfo; settings?: SettingsView; delivery?: string; status?: string;
|
||||
alert_action?: string; dismiss_action?: string;
|
||||
// What the add-on reads from the firmware (app 0.2.78): its X.Y.Z (null when unknown), how many tiles it holds,
|
||||
// whether it draws full-page tiles, and whether it takes several tiles that go to the same page.
|
||||
firmware_known?: string | null; tile_limit?: number; full_page?: boolean; page_tiles_repeat?: boolean;
|
||||
};
|
||||
export type ChangelogSection = { app: string; firmware: string; lines: string[] };
|
||||
export type Entity = { id: string; name: string; area?: string; device?: string; icon?: string; state?: string; tile?: boolean };
|
||||
export type IconInfo = { name: string; cp: string; label: string };
|
||||
export type Inventory = {
|
||||
@@ -40,6 +44,8 @@ export type Inventory = {
|
||||
builtin?: Entity[];
|
||||
pending?: { friendly: string; file: string; installed?: boolean; downloaded?: boolean; api_key?: string }[];
|
||||
updates?: { target: string; busy?: boolean; pending?: number; auto?: boolean };
|
||||
// The CHANGELOG by release, newest first: only in the full inventory, not in the live payload (app 0.2.78).
|
||||
changelog?: ChangelogSection[];
|
||||
claude_skill?: { path: string; installed: boolean; current: boolean; restart?: boolean };
|
||||
icons?: {
|
||||
groups: { label: string; icons: IconInfo[] }[];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// The add-on's answers: what a failed request says to the person using the page.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getJson, send } from "../src/api";
|
||||
|
||||
const answer = (status: number, body: string, type = "application/json") =>
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(body, { status, headers: { "Content-Type": type } })));
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("api", () => {
|
||||
it("shows the add-on's own sentence for any failed answer with a JSON error (app 0.2.78)", async () => {
|
||||
answer(400, JSON.stringify({ error: "Home Assistant refused light.turn_on: the entity is unavailable." }));
|
||||
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Home Assistant refused light.turn_on: the entity is unavailable.");
|
||||
answer(503, JSON.stringify({ error: "Home Assistant didn't answer in time. Try again in a moment." }));
|
||||
await expect(getJson("capabilities?entity=light.a")).rejects.toThrow("Home Assistant didn't answer in time. Try again in a moment.");
|
||||
answer(502, JSON.stringify({ error: "Bad gateway from Home Assistant." }));
|
||||
await expect(getJson("states")).rejects.toThrow("Bad gateway from Home Assistant.");
|
||||
});
|
||||
it("keeps the general sentence for an answer that isn't JSON or has no error in it", async () => {
|
||||
const general = "That didn't work. Refresh the page and try again.";
|
||||
answer(502, "<html><body>502 Bad Gateway</body></html>", "text/html");
|
||||
await expect(getJson("inventory")).rejects.toThrow(general);
|
||||
answer(500, JSON.stringify({ detail: "nope" }));
|
||||
await expect(getJson("inventory")).rejects.toThrow(general);
|
||||
answer(500, JSON.stringify({ error: "" }));
|
||||
await expect(getJson("inventory")).rejects.toThrow(general);
|
||||
});
|
||||
it("sends relative URLs with the CSRF header and reads an empty answer as null", async () => {
|
||||
answer(200, "");
|
||||
expect(await send("screens/x/identify", "POST")).toBeNull();
|
||||
const call = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toBe("api/screens/x/identify");
|
||||
expect(call[1].headers).toHaveProperty("X-Screen-CSRF");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
// The components that draw the state: a tile with live values, the library's filters, the ⌘K search.
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppSettingsView from "../src/components/AppSettingsView.vue";
|
||||
import CommandPalette from "../src/components/CommandPalette.vue";
|
||||
import Library from "../src/components/Library.vue";
|
||||
import Sidebar from "../src/components/Sidebar.vue";
|
||||
import TileCard from "../src/components/TileCard.vue";
|
||||
import TileInspector from "../src/components/TileInspector.vue";
|
||||
import { state } from "../src/store";
|
||||
import type { Inventory, Tile } from "../src/types";
|
||||
|
||||
@@ -17,7 +20,10 @@ function inventory(): Inventory {
|
||||
{ id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
|
||||
{ id: "cover.c", name: "Curtains", state: "open", area: "Living room" },
|
||||
],
|
||||
builtin: [{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" }],
|
||||
builtin: [
|
||||
{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" },
|
||||
{ id: "screen.page_1", name: "Go to page 1", device: "Built into the screen", area: "", state: "ok" },
|
||||
],
|
||||
icons: { groups: [], weather: { partlycloudy: "F0595" }, sun: { below_horizon: "F0594" }, defaults: { light: "F0335", sensor: "F050F", cover: "F1846" }, fallback: "F0335", builtin: { "screen.clock": "F0150" }, controls: { minus: "F0374", plus: "F0415" } },
|
||||
backgrounds: { auto: { label: "Default" }, none: { label: "None" } },
|
||||
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
|
||||
@@ -34,6 +40,7 @@ beforeEach(() => {
|
||||
state.search = ""; state.filter = ""; state.room = ""; state.hidePlaced = false;
|
||||
state.palette = false;
|
||||
state.selectedTile = null; state.inspector = null;
|
||||
state.dirty = false; state.tab = "layout";
|
||||
});
|
||||
|
||||
function placed(tile: Tile) {
|
||||
@@ -90,7 +97,7 @@ describe("TileCard", () => {
|
||||
it("opens the tile's settings on a click", async () => {
|
||||
const lamp = placed({ entity: "light.a", name: "", slot: 0 });
|
||||
await lamp.trigger("click");
|
||||
expect(state.selectedTile).toBe("light.a");
|
||||
expect(state.selectedTile?.entity).toBe("light.a");
|
||||
expect(state.inspector).toEqual({ kind: "tile" });
|
||||
expect(lamp.classes()).toContain("chosen");
|
||||
});
|
||||
@@ -101,7 +108,7 @@ describe("Library", () => {
|
||||
state.layout!.tiles.push({ entity: "light.a", name: "", slot: 0 });
|
||||
const library = mount(Library);
|
||||
const names = () => library.findAll(".ent .tx b").map((b) => b.text());
|
||||
expect(names()).toEqual(["Clock", "Lamp A", "Lamp B", "Temperature", "Curtains"]);
|
||||
expect(names()).toEqual(["Clock", "Go to page 1", "Lamp A", "Lamp B", "Temperature", "Curtains"]);
|
||||
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
|
||||
expect(library.find('.ent[title="light.a"] .av').classes()).toContain("on");
|
||||
expect(library.find('.ent[title="light.b"] .av').classes()).toContain("gone");
|
||||
@@ -110,7 +117,7 @@ describe("Library", () => {
|
||||
expect(names()).toEqual(["Lamp B"]);
|
||||
await library.find("#room").setValue("");
|
||||
await library.find("#hide-placed").trigger("click");
|
||||
expect(names()).toEqual(["Clock", "Lamp B", "Temperature", "Curtains"]);
|
||||
expect(names()).toEqual(["Clock", "Go to page 1", "Lamp B", "Temperature", "Curtains"]);
|
||||
state.filter = "light";
|
||||
await library.vm.$nextTick();
|
||||
expect(names()).toEqual(["Lamp B"]);
|
||||
@@ -146,3 +153,106 @@ describe("full-page and navigation tiles on the mockup", () => {
|
||||
expect(nav.find(".goto").text()).toBe("Page 3 ›");
|
||||
});
|
||||
});
|
||||
|
||||
describe("several tiles that go to the same page in the library (firmware 0.2.65)", () => {
|
||||
it("keeps offering a placed navigation tile when the screen takes several, and adds another copy", async () => {
|
||||
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
|
||||
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 }, { entity: "light.a", name: "", slot: 1 });
|
||||
const library = mount(Library);
|
||||
const row = () => library.find('.ent[title="screen.page_1"]');
|
||||
expect(row().attributes("disabled")).toBeUndefined();
|
||||
expect(row().find(".add").text()).toBe("+");
|
||||
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
|
||||
await library.find("#hide-placed").trigger("click");
|
||||
expect(row().exists()).toBe(true);
|
||||
await row().trigger("click");
|
||||
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
|
||||
});
|
||||
it("marks it placed when the screen takes one per page", () => {
|
||||
Object.assign(state.inventory.screens[0], { page_tiles_repeat: false });
|
||||
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 });
|
||||
const library = mount(Library);
|
||||
expect(library.find('.ent[title="screen.page_1"]').attributes("disabled")).toBeDefined();
|
||||
expect(library.find('.ent[title="screen.page_1"] .add').text()).toBe("✓");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TileInspector: pages (app 0.2.78)", () => {
|
||||
const row = (wrapper: ReturnType<typeof mount>, label: string) =>
|
||||
wrapper.findAll(".f").find((f) => f.find(".f-label").exists() && f.find(".f-label").text() === label)!;
|
||||
const choices = (wrapper: ReturnType<typeof mount>, label: string) => row(wrapper, label).findAll(".seg button").map((b) => b.text());
|
||||
function open(tiles: any[], index: number) {
|
||||
state.layout!.tiles.push(...tiles);
|
||||
state.layout!.pages = 1;
|
||||
const tile = state.layout!.tiles[index];
|
||||
return { tile, drawer: mount(TileInspector, { props: { tile } }) };
|
||||
}
|
||||
it("offers the pages the screen has and the empty one after them as targets, and keeps one beyond those", async () => {
|
||||
Object.assign(state.inventory.screens[0], { firmware: "0.2.63", full_page: true });
|
||||
const { tile, drawer } = open([
|
||||
{ entity: "light.a", name: "", slot: 0 }, { entity: "screen.page_2", name: "", slot: 1 }, { entity: "sensor.t", name: "", slot: 6 },
|
||||
], 1);
|
||||
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)"]);
|
||||
expect(row(drawer, "Goes to page").find('[aria-pressed="true"]').text()).toBe("2");
|
||||
tile.entity = "screen.page_7";
|
||||
await drawer.vm.$nextTick();
|
||||
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "7 (empty)"]);
|
||||
expect(row(drawer, "Goes to page").find("small.warn").text()).toMatch(/no page 7/);
|
||||
await row(drawer, "Goes to page").findAll(".seg button")[2].trigger("click");
|
||||
expect(tile.entity).toBe("screen.page_3");
|
||||
expect(state.layout!.pages).toBe(3);
|
||||
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "4 (empty)"]);
|
||||
expect(row(drawer, "Goes to page").find("small").classes()).not.toContain("warn");
|
||||
});
|
||||
it("moves the tile to another page or a new one with a tap", async () => {
|
||||
const { tile, drawer } = open([{ entity: "light.a", name: "", slot: 0 }, { entity: "sensor.t", name: "", slot: 6 }], 0);
|
||||
expect(choices(drawer, "Page")).toEqual(["1", "2", "New page"]);
|
||||
expect(row(drawer, "Page").find('[aria-pressed="true"]').text()).toBe("1");
|
||||
await row(drawer, "Page").findAll(".seg button")[1].trigger("click");
|
||||
expect(tile.slot).toBe(7);
|
||||
expect(state.dirty).toBe(true);
|
||||
// Alone on the last page now: a new page would only leave this one empty.
|
||||
const sensor = state.layout!.tiles.find((t) => t.entity === "sensor.t")!;
|
||||
await row(drawer, "Page").findAll(".seg button")[0].trigger("click");
|
||||
expect(tile.slot).toBe(0);
|
||||
expect(sensor.slot).toBe(6);
|
||||
const other = mount(TileInspector, { props: { tile: sensor } });
|
||||
expect(choices(other, "Page")).toEqual(["1", "2"]);
|
||||
await row(other, "Page").findAll(".seg button")[0].trigger("click");
|
||||
expect(sensor.slot).toBe(1);
|
||||
// One tile on one page: nowhere to go, so no row.
|
||||
state.layout!.tiles = [];
|
||||
const only = open([{ entity: "light.b", name: "", slot: 0 }], 0).drawer;
|
||||
expect(row(only, "Page")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("marks the open screen with unsaved edits and keeps them when it is chosen again", async () => {
|
||||
state.dirty = true;
|
||||
const layout = state.layout;
|
||||
state.tab = "settings";
|
||||
const sidebar = mount(Sidebar);
|
||||
const item = sidebar.find("#screens .nav-item");
|
||||
expect(item.find(".unsaved").exists()).toBe(true);
|
||||
await item.trigger("click");
|
||||
expect(state.layout).toBe(layout);
|
||||
expect(state.dirty).toBe(true);
|
||||
expect(state.tab).toBe("layout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppSettingsView", () => {
|
||||
it("shows what the current firmware brings from the changelog of the full inventory (app 0.2.78)", () => {
|
||||
state.inventory.updates = { target: "0.2.65", pending: 0 };
|
||||
state.inventory.changelog = [
|
||||
{ app: "0.2.78", firmware: "0.2.65", lines: ["Several tiles go to the same page."] },
|
||||
{ app: "0.2.76", firmware: "0.2.63", lines: ["Older."] },
|
||||
];
|
||||
const view = mount(AppSettingsView);
|
||||
expect(view.find(".whatsnew summary").text()).toBe("What's new in firmware 0.2.65");
|
||||
expect(view.findAll(".whatsnew li").map((li) => li.text())).toEqual(["Several tiles go to the same page."]);
|
||||
delete state.inventory.changelog;
|
||||
expect(mount(AppSettingsView).find(".whatsnew").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// What a drag near an edge scrolls (app 0.2.78): on a phone the row of pages sideways and the page itself up and
|
||||
// down, on a wide window the canvas both ways.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { dragScrollers, edgeStep, scrollsAlong } from "../src/drag";
|
||||
|
||||
// jsdom has no layout: give an element the sizes a browser would measure, and the overflow longhands it computes.
|
||||
function sized(element: HTMLElement, size: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }) {
|
||||
for (const [key, value] of Object.entries(size)) Object.defineProperty(element, key, { configurable: true, value });
|
||||
return element;
|
||||
}
|
||||
let canvas: HTMLElement, pages: HTMLElement;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div class="canvas"><div class="pages"></div></div>';
|
||||
canvas = document.querySelector<HTMLElement>(".canvas")!;
|
||||
pages = document.querySelector<HTMLElement>(".pages")!;
|
||||
});
|
||||
|
||||
describe("the scroller of a drag", () => {
|
||||
it("scrolls the canvas both ways on a wide window", () => {
|
||||
// The canvas scrolls (overflow: auto); the row of pages is wider than it but doesn't scroll itself.
|
||||
Object.assign(canvas.style, { overflowX: "auto", overflowY: "auto" });
|
||||
sized(canvas, { scrollWidth: 1800, clientWidth: 900, scrollHeight: 1200, clientHeight: 700 });
|
||||
sized(pages, { scrollWidth: 1800, clientWidth: 900 });
|
||||
const { x, y } = dragScrollers(document);
|
||||
expect(x).toBe(canvas);
|
||||
expect(y).toBe(canvas);
|
||||
});
|
||||
it("scrolls the row of pages sideways and the page up and down on a phone", () => {
|
||||
// At 960 px and narrower: the canvas is overflow: visible and the row of pages overflow-x: auto.
|
||||
Object.assign(canvas.style, { overflowX: "visible", overflowY: "visible" });
|
||||
pages.style.overflowX = "auto";
|
||||
sized(canvas, { scrollWidth: 390, clientWidth: 390, scrollHeight: 900, clientHeight: 900 });
|
||||
sized(pages, { scrollWidth: 1400, clientWidth: 358 });
|
||||
const { x, y } = dragScrollers(document);
|
||||
expect(x).toBe(pages);
|
||||
expect(y).toBe(document.scrollingElement);
|
||||
});
|
||||
it("falls back to the canvas when the row of pages fits", () => {
|
||||
pages.style.overflowX = "auto";
|
||||
sized(pages, { scrollWidth: 358, clientWidth: 358 });
|
||||
expect(dragScrollers(document).x).toBe(canvas);
|
||||
expect(scrollsAlong(null, "x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the step near an edge", () => {
|
||||
it("moves 12 px towards an edge within 60 px of it, and not in between", () => {
|
||||
expect(edgeStep(10, [0, 400])).toBe(-12);
|
||||
expect(edgeStep(200, [0, 400])).toBe(0);
|
||||
expect(edgeStep(390, [0, 400])).toBe(12);
|
||||
expect(edgeStep(120, [100, 900])).toBe(-12);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,9 @@ describe("defaults, controls and versions", () => {
|
||||
expect(versionAtLeast("1.0.0", "0.9.9")).toBe(true);
|
||||
expect(versionAtLeast(undefined, "0.2.1")).toBe(false);
|
||||
expect(versionAtLeast("unknown", "0.2.1")).toBe(false);
|
||||
// Strict X.Y.Z: anything else is not a firmware release (the add-on works out firmware_known, app 0.2.78).
|
||||
expect(versionAtLeast("0.2.63 (ESPHome 2026.6.2)", "0.2.1")).toBe(false);
|
||||
expect(versionAtLeast(null, "0.2.1")).toBe(false);
|
||||
expect(tileLimit("0.2.6")).toBe(10);
|
||||
expect(tileLimit("0.2.7")).toBe(20);
|
||||
expect(tileLimit("0.2.60")).toBe(20);
|
||||
|
||||
+224
-4
@@ -1,8 +1,8 @@
|
||||
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addTile, canAlert, copyLayoutFrom, importLayout, layoutJson, liveOf, PHASES, removeTile, retargetPageTile, select, setTileOption, state, tileLimit,
|
||||
topbarItems, topbarView, updateProgress, whatsNew,
|
||||
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageTilesRepeat, PHASES, removeTile,
|
||||
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
|
||||
} from "../src/store";
|
||||
import type { Inventory, Screen } from "../src/types";
|
||||
|
||||
@@ -22,11 +22,13 @@ function inventory(): Inventory {
|
||||
{ id: "light.a", name: "Lamp A", state: "on", area: "Living room" }, { id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
|
||||
{ id: "switch.c", name: "Coffee", state: "off", area: "Kitchen" }, { id: "light.b", name: "Lamp B", state: "off", area: "Kitchen" },
|
||||
],
|
||||
updates: { target: "0.2.62", changelog: [
|
||||
updates: { target: "0.2.62" },
|
||||
// The full inventory carries the changelog next to the screens, not in the update summary (app 0.2.78).
|
||||
changelog: [
|
||||
{ app: "0.2.74", firmware: "0.2.62", lines: ["Full-page tiles.", "Live values."] },
|
||||
{ app: "0.2.71", firmware: "0.2.60", lines: ["Slider stays put."] },
|
||||
{ app: "0.2.40", firmware: "0.2.34", lines: ["English."] },
|
||||
] } as any,
|
||||
],
|
||||
alerts: { min_firmware: "0.2.31" },
|
||||
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
|
||||
backgrounds: { auto: { label: "Default" }, orange: { label: "Orange", color: "#ffe1c6" } },
|
||||
@@ -188,3 +190,221 @@ describe("full-page and navigation tiles", () => {
|
||||
expect(retargetPageTile(nav, 9)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the open screen chosen again (app 0.2.78)", () => {
|
||||
it("keeps unsaved edits and only brings the layout back into view", () => {
|
||||
select("living");
|
||||
addTile("light.b");
|
||||
state.tab = "settings";
|
||||
state.route = "#firmware";
|
||||
select("living");
|
||||
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
|
||||
expect(state.dirty).toBe(true);
|
||||
expect(state.tab).toBe("layout");
|
||||
expect(state.inspector).toBeNull();
|
||||
expect(state.route).toBe("");
|
||||
expect(window.confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reads the stored layout again when nothing is unsaved, so a change from Claude or another tab shows up", () => {
|
||||
select("living");
|
||||
state.inventory.screens[0].layout.tiles.push({ entity: "light.b", name: "", slot: 2 });
|
||||
select("living");
|
||||
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
|
||||
expect(state.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving while you keep editing (app 0.2.78)", () => {
|
||||
// The PUT waits until the test answers it; every other request fails, which the store shrugs off.
|
||||
function slowServer() {
|
||||
const control = { answer: () => {} };
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => init?.method !== "PUT"
|
||||
? Promise.reject(new Error("offline"))
|
||||
: new Promise((resolve) => { control.answer = () => resolve({ ok: true, status: 200, text: async () => "{}", json: async () => ({}) }); }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { control, fetchMock };
|
||||
}
|
||||
it("marks the layout saved when nothing changed while the request was on its way", async () => {
|
||||
select("living");
|
||||
addTile("light.b");
|
||||
const { control } = slowServer();
|
||||
const saving = save();
|
||||
control.answer();
|
||||
await saving;
|
||||
expect(state.dirty).toBe(false);
|
||||
expect(state.saved).toBeGreaterThan(0);
|
||||
expect(state.toast?.message).toBe("Saved. Your screen is being updated.");
|
||||
});
|
||||
it("keeps a change made during the save unsaved and says so", async () => {
|
||||
select("living");
|
||||
addTile("light.b");
|
||||
const { control, fetchMock } = slowServer();
|
||||
const saving = save();
|
||||
setTileOption(state.layout!.tiles[0], "background", "orange");
|
||||
control.answer();
|
||||
await saving;
|
||||
expect(state.dirty).toBe(true);
|
||||
expect(state.saved).toBe(0);
|
||||
expect(state.toast?.message).toBe("Saved. Your newest change isn't sent yet: press Save & send again.");
|
||||
// What went out is the layout from before that change.
|
||||
const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body);
|
||||
expect(body.tiles.map((t: any) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
|
||||
expect(body.tiles[0].options?.background).toBeUndefined();
|
||||
});
|
||||
it("leaves the unsaved flag of a screen opened meanwhile alone", async () => {
|
||||
select("living");
|
||||
addTile("light.b");
|
||||
const { control } = slowServer();
|
||||
const saving = save();
|
||||
select("kitchen");
|
||||
addTile("light.b");
|
||||
control.answer();
|
||||
await saving;
|
||||
expect(state.selected).toBe("kitchen");
|
||||
expect(state.dirty).toBe(true);
|
||||
expect(state.toast?.message).toBe("Saved. Living room is being updated.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moving a tile to another page without dragging (app 0.2.78)", () => {
|
||||
it("takes the first free cell of that page, or a new page after the last one", () => {
|
||||
select("living");
|
||||
const [lamp, sensor] = state.layout!.tiles;
|
||||
state.layout!.pages = 2;
|
||||
expect(moveTileToPage(sensor, 1)).toBe(true);
|
||||
expect(sensor.slot).toBe(6);
|
||||
expect(moveTileToPage(lamp, 1)).toBe(true);
|
||||
expect(lamp.slot).toBe(7);
|
||||
expect(moveTileToPage(lamp, 2)).toBe(true);
|
||||
expect(lamp.slot).toBe(12);
|
||||
expect(state.layout!.pages).toBe(3);
|
||||
expect(state.dirty).toBe(true);
|
||||
expect(moveTileToPage(lamp, 2)).toBe(false);
|
||||
expect(moveTileToPage(lamp, 8)).toBe(false);
|
||||
});
|
||||
it("swaps with the first tile of a full page, as a drop there does", () => {
|
||||
select("living");
|
||||
state.layout!.tiles = [
|
||||
...Array.from({ length: 6 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i })),
|
||||
{ entity: "light.b", name: "", slot: 6 },
|
||||
];
|
||||
const b = state.layout!.tiles[6];
|
||||
expect(moveTileToPage(b, 0)).toBe(true);
|
||||
expect(b.slot).toBe(0);
|
||||
expect(state.layout!.tiles.find((t) => t.entity === "light.l0")!.slot).toBe(6);
|
||||
});
|
||||
it("moves a full-page tile to a page of its own and says so when a tile finds no room", () => {
|
||||
select("living");
|
||||
state.layout!.tiles = [
|
||||
{ entity: "light.big", name: "", slot: 0, options: { size: "full" } },
|
||||
{ entity: "light.w", name: "", slot: 6, options: { size: "wide" } },
|
||||
...[8, 9, 10, 11].map((slot) => ({ entity: `light.p${slot}`, name: "", slot })),
|
||||
...Array.from({ length: 36 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: 12 + i })),
|
||||
];
|
||||
const [big, wide] = state.layout!.tiles;
|
||||
// Page 1 holds the full-page tile and every other page is taken: the wide tile would have to push it off.
|
||||
expect(moveTileToPage(wide, 0)).toBe(false);
|
||||
expect(wide.slot).toBe(6);
|
||||
expect(state.toast?.message).toBe("There is no room for this tile on page 1.");
|
||||
// The full-page tile and the tiles of page 2 change places.
|
||||
expect(moveTileToPage(big, 1)).toBe(true);
|
||||
expect(big.slot).toBe(6);
|
||||
expect(state.layout!.tiles.filter((t) => t.slot < 6).map((t) => t.entity).sort()).toEqual(["light.p10", "light.p11", "light.p8", "light.p9", "light.w"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the add-on says about a screen's firmware (app 0.2.78)", () => {
|
||||
it("takes the tile limit and the features from the screen entry", () => {
|
||||
const living = state.inventory.screens[0];
|
||||
Object.assign(living, { firmware: "unknown", firmware_known: null, tile_limit: 48, full_page: true, page_tiles_repeat: true });
|
||||
select("living");
|
||||
expect(tileLimit.value).toBe(48);
|
||||
expect(fullPage.value).toBe(true);
|
||||
expect(pageTilesRepeat.value).toBe(true);
|
||||
// A version Home Assistant can't report right now no longer cuts a copied or imported layout to ten.
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
|
||||
importLayout(JSON.stringify({ tiles: many }));
|
||||
expect(state.layout!.tiles).toHaveLength(30);
|
||||
expect(state.toast?.message).toBe("Layout imported. Save & send when it looks right.");
|
||||
});
|
||||
it("goes by firmware_known for the version and the notes", () => {
|
||||
const living = state.inventory.screens[0];
|
||||
Object.assign(living, { firmware: "unknown", firmware_known: "0.2.60" });
|
||||
select("living");
|
||||
expect(supports(0, 2, 60)).toBe(true);
|
||||
expect(supports(0, 2, 61)).toBe(false);
|
||||
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
|
||||
expect(canAlert(living)).toBe(true);
|
||||
Object.assign(living, { firmware: "0.2.63", firmware_known: null });
|
||||
expect(supports(0, 2, 31)).toBe(false);
|
||||
expect(canAlert(living)).toBe(false);
|
||||
});
|
||||
it("keeps today's rule, with a strict X.Y.Z, for a screen entry without the fields", () => {
|
||||
select("living");
|
||||
expect(tileLimit.value).toBe(20);
|
||||
expect(fullPage.value).toBe(false);
|
||||
expect(pageTilesRepeat.value).toBe(false);
|
||||
state.inventory.screens[0].firmware = "0.2.65";
|
||||
expect(tileLimit.value).toBe(48);
|
||||
expect(fullPage.value).toBe(true);
|
||||
expect(pageTilesRepeat.value).toBe(true);
|
||||
state.inventory.screens[0].firmware = "0.2.65 (ESPHome 2026.6.2)";
|
||||
expect(tileLimit.value).toBe(10);
|
||||
expect(fullPage.value).toBe(false);
|
||||
state.inventory.screens[0].firmware = "unknown";
|
||||
expect(tileLimit.value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the changelog from the full inventory (app 0.2.78)", () => {
|
||||
it("reads the notes next to the screens and ignores an update summary that still has them", () => {
|
||||
const living = state.inventory.screens[0];
|
||||
state.inventory.updates = { target: "0.2.62", changelog: [{ app: "0.2.1", firmware: "0.2.62", lines: ["Old place."] }] } as any;
|
||||
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
|
||||
});
|
||||
it("shows nothing without a changelog", () => {
|
||||
delete state.inventory.changelog;
|
||||
expect(whatsNew(state.inventory.screens[1])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("several tiles that go to the same page (firmware 0.2.65)", () => {
|
||||
it("adds, retargets and imports another copy when the screen takes them", () => {
|
||||
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
|
||||
select("living");
|
||||
addTile("screen.page_1");
|
||||
addTile("screen.page_1");
|
||||
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
|
||||
addTile("screen.page_2");
|
||||
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_2")!;
|
||||
expect(retargetPageTile(nav, 1)).toBe(true);
|
||||
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(3);
|
||||
// Only a navigation tile repeats; any other entity still appears once.
|
||||
addTile("light.a");
|
||||
expect(state.layout!.tiles.filter((t) => t.entity === "light.a")).toHaveLength(1);
|
||||
importLayout(JSON.stringify({ tiles: [
|
||||
{ entity: "screen.page_1", slot: 0 }, { entity: "screen.page_1", slot: 6 }, { entity: "light.a", slot: 1 }, { entity: "light.a", slot: 2 },
|
||||
] }));
|
||||
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["screen.page_1", "light.a", "screen.page_1"]);
|
||||
});
|
||||
it("keeps one tile per page when the screen doesn't", () => {
|
||||
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: false });
|
||||
select("living");
|
||||
addTile("screen.page_1");
|
||||
addTile("screen.page_1");
|
||||
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(1);
|
||||
addTile("screen.page_2");
|
||||
expect(retargetPageTile(state.layout!.tiles.find((t) => t.entity === "screen.page_2")!, 1)).toBe(false);
|
||||
expect(state.toast?.message).toMatch(/already has a tile that goes to page 1/);
|
||||
});
|
||||
it("starts the empty page after the last one when a tile goes there", () => {
|
||||
select("living");
|
||||
addTile("screen.page_1");
|
||||
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_1")!;
|
||||
expect(state.layout!.pages).toBe(1);
|
||||
expect(retargetPageTile(nav, 1)).toBe(false);
|
||||
expect(retargetPageTile(nav, 2)).toBe(true);
|
||||
expect(state.layout!.pages).toBe(2);
|
||||
expect(state.dirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user