diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2dbf2e8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index b43899b..54ccae3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README_EXTENDED.md b/README_EXTENDED.md
index 0171263..03b607a 100644
--- a/README_EXTENDED.md
+++ b/README_EXTENDED.md
@@ -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.
@@ -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.
@@ -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
diff --git a/components/smart_display/cyd_ui.h b/components/smart_display/cyd_ui.h
index 870a846..19c20c4 100644
--- a/components/smart_display/cyd_ui.h
+++ b/components/smart_display/cyd_ui.h
@@ -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;
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index 808e0be..1c46e62 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -7,15 +7,19 @@
#include
#include
#include
+#include
// 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_, 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_ (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('0' + MAX_PAGES);
+ return page_entity(entity) && entity[12] >= '1' && entity[12] <= static_cast('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_, 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 struct TileAllocator {
using value_type = T;
TileAllocator() = default;
template TileAllocator(const TileAllocator &) {}
- T *allocate(size_t n) { return esphome::RAMAllocator().allocate(n); }
+ T *allocate(size_t n) {
+ T *p = esphome::RAMAllocator().allocate(n);
+ if (!p) {
+ ESP_LOGE("runtime", "No memory for %u tiles (%u bytes)", static_cast(n), static_cast(n * sizeof(T)));
+ abort();
+ }
+ return p;
+ }
void deallocate(T *p, size_t n) { esphome::RAMAllocator().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>;
#else
using TileList = std::vector;
#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().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 &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 &entities, const std::string &name, bool &changed,
const std::vector &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) {
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index c53af91..ba74c11 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -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(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() ? static_cast(std::clamp(root["pages"].as(), 1, MAX_PAGES)) : 1;
if(root["swipe_pages"].is() && swipe_pages!=root["swipe_pages"].as()){
@@ -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(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<=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<` 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=`),
+ 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_` 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 `.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
diff --git a/docs/TEST_RESULTS_0233.md b/docs/TEST_RESULTS_0233.md
index 19840e8..d1e2906 100644
--- a/docs/TEST_RESULTS_0233.md
+++ b/docs/TEST_RESULTS_0233.md
@@ -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.
diff --git a/docs/TEST_RESULTS_0272.md b/docs/TEST_RESULTS_0272.md
index 11ea687..fac410e 100644
--- a/docs/TEST_RESULTS_0272.md
+++ b/docs/TEST_RESULTS_0272.md
@@ -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).
diff --git a/docs/TEST_RESULTS_0274.md b/docs/TEST_RESULTS_0274.md
index 9f9f2e5..d20fbab 100644
--- a/docs/TEST_RESULTS_0274.md
+++ b/docs/TEST_RESULTS_0274.md
@@ -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_` 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--device.yaml
--device `), 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 [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
diff --git a/docs/TEST_RESULTS_0275.md b/docs/TEST_RESULTS_0275.md
index f240d3e..718fdc4 100644
--- a/docs/TEST_RESULTS_0275.md
+++ b/docs/TEST_RESULTS_0275.md
@@ -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
diff --git a/docs/TEST_RESULTS_0278.md b/docs/TEST_RESULTS_0278.md
new file mode 100644
index 0000000..c682714
--- /dev/null
+++ b/docs/TEST_RESULTS_0278.md
@@ -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.
diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml
index 14653a5..e6118aa 100644
--- a/guition-4848s040.yaml
+++ b/guition-4848s040.yaml
@@ -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
diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml
index 97e5963..308eed2 100644
--- a/home-like-2432s028.yaml
+++ b/home-like-2432s028.yaml
@@ -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
diff --git a/packages/cyd.yaml b/packages/cyd.yaml
index 210e469..b30563a 100644
--- a/packages/cyd.yaml
+++ b/packages/cyd.yaml
@@ -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
diff --git a/packages/guition.yaml b/packages/guition.yaml
index 6e309fc..03f5061 100644
--- a/packages/guition.yaml
+++ b/packages/guition.yaml
@@ -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
diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md
index e4aac08..02c71d3 100644
--- a/screen_manager/CHANGELOG.md
+++ b/screen_manager/CHANGELOG.md
@@ -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)
diff --git a/screen_manager/DOCS.md b/screen_manager/DOCS.md
index fcae8e6..37770e8 100644
--- a/screen_manager/DOCS.md
+++ b/screen_manager/DOCS.md
@@ -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
diff --git a/screen_manager/app/camera_feed.py b/screen_manager/app/camera_feed.py
index 6928709..3b1a20e 100644
--- a/screen_manager/app/camera_feed.py
+++ b/screen_manager/app/camera_feed.py
@@ -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):
diff --git a/screen_manager/app/changelog.py b/screen_manager/app/changelog.py
index c8ef049..b7e08f3 100644
--- a/screen_manager/app/changelog.py
+++ b/screen_manager/app/changelog.py
@@ -3,39 +3,73 @@
The Dockerfile copies CHANGELOG.md next to this file; a development checkout has it one folder up.
Each `## (firmware )` 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'(?= 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()
diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py
index d062258..358c4cc 100644
--- a/screen_manager/app/claude_skill.py
+++ b/screen_manager/app/claude_skill.py
@@ -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_`: 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_` 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_`: 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_` 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
diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py
index 0edcea6..ab35d05 100644
--- a/screen_manager/app/core.py
+++ b/screen_manager/app/core.py
@@ -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_ 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_ 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 " (ESPHome )".
+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 elsewhere:
+ raise ValueError('Not on page %d: %s. Move it there first.' % (page + 1, ', '.join(elsewhere)))
+ 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:
- 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)):
+ 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:
- 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)
+ 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 '',
diff --git a/screen_manager/app/firmware.py b/screen_manager/app/firmware.py
index 48288da..9febfc6 100644
--- a/screen_manager/app/firmware.py
+++ b/screen_manager/app/firmware.py
@@ -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'})
diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py
index 9309283..5e73677 100644
--- a/screen_manager/app/server.py
+++ b/screen_manager/app/server.py
@@ -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')
- asyncio.run(main())
+ # 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())
diff --git a/screen_manager/app/static/assets/index-BL5pkID8.css b/screen_manager/app/static/assets/index-BL5pkID8.css
deleted file mode 100644
index 4b81dcd..0000000
--- a/screen_manager/app/static/assets/index-BL5pkID8.css
+++ /dev/null
@@ -1 +0,0 @@
-:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-BawP5y9D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-H0q_-5Ia.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-Bn7ZL9yA.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.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)}.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)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.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}.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}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.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}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px}
diff --git a/screen_manager/app/static/assets/index-Cj3m_z3Z.js b/screen_manager/app/static/assets/index-Cj3m_z3Z.js
deleted file mode 100644
index 882c560..0000000
--- a/screen_manager/app/static/assets/index-Cj3m_z3Z.js
+++ /dev/null
@@ -1,28 +0,0 @@
-(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const l of o)if(l.type==="childList")for(const a of l.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&s(a)}).observe(document,{childList:!0,subtree:!0});function n(o){const l={};return o.integrity&&(l.integrity=o.integrity),o.referrerPolicy&&(l.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?l.credentials="include":o.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(o){if(o.ep)return;o.ep=!0;const l=n(o);fetch(o.href,l)}})();function Wo(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const xe={},un=[],xt=()=>{},ul=()=>!1,Ns=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Rs=e=>e.startsWith("onUpdate:"),He=Object.assign,Ko=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Ba=Object.prototype.hasOwnProperty,me=(e,t)=>Ba.call(e,t),te=Array.isArray,zt=e=>rs(e)==="[object Map]",Dt=e=>rs(e)==="[object Set]",vi=e=>rs(e)==="[object Date]",re=e=>typeof e=="function",Pe=e=>typeof e=="string",St=e=>typeof e=="symbol",_e=e=>e!==null&&typeof e=="object",cl=e=>(_e(e)||re(e))&&re(e.then)&&re(e.catch),dl=Object.prototype.toString,rs=e=>dl.call(e),Va=e=>rs(e).slice(8,-1),fl=e=>rs(e)==="[object Object]",qo=e=>Pe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Wn=Wo(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Hs=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Wa=/-\w/g,Ye=Hs(e=>e.replace(Wa,t=>t.slice(1).toUpperCase())),Ka=/\B([A-Z])/g,tn=Hs(e=>e.replace(Ka,"-$1").toLowerCase()),Us=Hs(e=>e.charAt(0).toUpperCase()+e.slice(1)),so=Hs(e=>e?`on${Us(e)}`:""),$t=(e,t)=>!Object.is(e,t),bs=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},js=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let mi;const Bs=()=>mi||(mi=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Ae(e){if(te(e)){const t={};for(let n=0;nn.startsWith("/*")?"":n).split(qa).forEach(n=>{if(n){const s=n.split(Ya);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function le(e){let t="";if(Pe(e))t=e;else if(te(e))for(let n=0;nft(n,t))}const vl=e=>!!(e&&e.__v_isRef===!0),m=e=>Pe(e)?e:e==null?"":te(e)||_e(e)&&(e.toString===dl||!re(e.toString))?vl(e)?m(e.value):JSON.stringify(e,ml,2):String(e),ml=(e,t)=>vl(t)?ml(e,t.value):zt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,o],l)=>(n[oo(s,l)+" =>"]=o,n),{})}:Dt(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>oo(n))}:St(t)?oo(t):_e(t)&&!te(t)&&!fl(t)?String(t):t,oo=(e,t="")=>{var n;return St(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};let Re;class er{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Re&&(Re.active?(this.parent=Re,this.index=(Re.scopes||(Re.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes){const s=this.scopes.slice();for(t=0,n=s.length;t0&&--this._on===0){if(Re===this)Re=this.prevScope;else{let t=Re;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(qn){let t=qn;for(qn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Kn;){let t=Kn;for(Kn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function wl(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function _l(e){let t,n=e.depsTail,s=n;for(;s;){const o=s.prevDep;s.version===-1?(s===n&&(n=o),Go(s),nr(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=o}e.deps=t,e.depsTail=n}function $o(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(kl(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function kl(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Xn)||(e.globalVersion=Xn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!$o(e))))return;e.flags|=2;const t=e.dep,n=Ce,s=dt;Ce=e,dt=!0;try{wl(e);const o=e.fn(e._value);(t.version===0||$t(o,e._value))&&(e.flags|=128,e._value=o,t.version++)}catch(o){throw t.version++,o}finally{Ce=n,dt=s,_l(e),e.flags&=-3}}function Go(e,t=!1){const{dep:n,prevSub:s,nextSub:o}=e;if(s&&(s.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let l=n.computed.deps;l;l=l.nextDep)Go(l,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function nr(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let dt=!0;const $l=[];function Ft(){$l.push(dt),dt=!1}function Nt(){const e=$l.pop();dt=e===void 0?!0:e}function bi(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Ce;Ce=void 0;try{t()}finally{Ce=n}}}let Xn=0;class sr{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Xo{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Ce||!dt||Ce===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Ce)n=this.activeLink=new sr(Ce,this),Ce.deps?(n.prevDep=Ce.depsTail,Ce.depsTail.nextDep=n,Ce.depsTail=n):Ce.deps=Ce.depsTail=n,xl(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=Ce.depsTail,n.nextDep=void 0,Ce.depsTail.nextDep=n,Ce.depsTail=n,Ce.deps===n&&(Ce.deps=s)}return n}trigger(t){this.version++,Xn++,this.notify(t)}notify(t){zo();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Jo()}}}function xl(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)xl(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const xo=new WeakMap,cn=Symbol(""),So=Symbol(""),Zn=Symbol("");function Ue(e,t,n){if(dt&&Ce){let s=xo.get(e);s||xo.set(e,s=new Map);let o=s.get(n);o||(s.set(n,o=new Xo),o.map=s,o.key=n),o.track()}}function It(e,t,n,s,o,l){const a=xo.get(e);if(!a){Xn++;return}const r=c=>{c&&c.trigger()};if(zo(),t==="clear")a.forEach(r);else{const c=te(e),f=c&&qo(n);if(c&&n==="length"){const u=Number(s);a.forEach((h,w)=>{(w==="length"||w===Zn||!St(w)&&w>=u)&&r(h)})}else switch((n!==void 0||a.has(void 0))&&r(a.get(n)),f&&r(a.get(Zn)),t){case"add":c?f&&r(a.get("length")):(r(a.get(cn)),zt(e)&&r(a.get(So)));break;case"delete":c||(r(a.get(cn)),zt(e)&&r(a.get(So)));break;case"set":zt(e)&&r(a.get(cn));break}}Jo()}function _n(e){const t=ve(e);return t===e||(Ue(t,"iterate",Zn),at(e))?t:Ct(e)?Jt(e)?t.map(n=>Gt(rt(n))):t.map(Gt):t.map(rt)}function Vs(e){return Ue(e=ve(e),"iterate",Zn),e}function _t(e,t){return Ct(e)?Gt(Jt(e)?rt(t):t):rt(t)}const or={__proto__:null,[Symbol.iterator](){return lo(this,Symbol.iterator,e=>_t(this,e))},concat(...e){return _n(this).concat(...e.map(t=>te(t)?_n(t):t))},entries(){return lo(this,"entries",e=>(e[1]=_t(this,e[1]),e))},every(e,t){return At(this,"every",e,t,void 0,arguments)},filter(e,t){return At(this,"filter",e,t,n=>n.map(s=>_t(this,s)),arguments)},find(e,t){return At(this,"find",e,t,n=>_t(this,n),arguments)},findIndex(e,t){return At(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return At(this,"findLast",e,t,n=>_t(this,n),arguments)},findLastIndex(e,t){return At(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return At(this,"forEach",e,t,void 0,arguments)},includes(...e){return ao(this,"includes",e)},indexOf(...e){return ao(this,"indexOf",e)},join(e){return _n(this).join(e)},lastIndexOf(...e){return ao(this,"lastIndexOf",e)},map(e,t){return At(this,"map",e,t,void 0,arguments)},pop(){return Ln(this,"pop")},push(...e){return Ln(this,"push",e)},reduce(e,...t){return wi(this,"reduce",e,t)},reduceRight(e,...t){return wi(this,"reduceRight",e,t)},shift(){return Ln(this,"shift")},some(e,t){return At(this,"some",e,t,void 0,arguments)},splice(...e){return Ln(this,"splice",e)},toReversed(){return _n(this).toReversed()},toSorted(e){return _n(this).toSorted(e)},toSpliced(...e){return _n(this).toSpliced(...e)},unshift(...e){return Ln(this,"unshift",e)},values(){return lo(this,"values",e=>_t(this,e))}};function lo(e,t,n){const s=Vs(e),o=s[t]();return s!==e&&!at(e)&&(o._next=o.next,o.next=()=>{const l=o._next();return l.done||(l.value=n(l.value)),l}),o}const ir=Array.prototype;function At(e,t,n,s,o,l){const a=Vs(e),r=a!==e&&!at(e),c=a[t];if(c!==ir[t]){const h=c.apply(e,l);return r?rt(h):h}let f=n;a!==e&&(r?f=function(h,w){return n.call(this,_t(e,h),w,e)}:n.length>2&&(f=function(h,w){return n.call(this,h,w,e)}));const u=c.call(a,f,s);return r&&o?o(u):u}function wi(e,t,n,s){const o=Vs(e),l=o!==e&&!at(e);let a=n,r=!1;o!==e&&(l?(r=s.length===0,a=function(f,u,h){return r&&(r=!1,f=_t(e,f)),n.call(this,f,_t(e,u),h,e)}):n.length>3&&(a=function(f,u,h){return n.call(this,f,u,h,e)}));const c=o[t](a,...s);return r?_t(e,c):c}function ao(e,t,n){const s=ve(e);Ue(s,"iterate",Zn);const o=s[t](...n);return(o===-1||o===!1)&&ei(n[0])?(n[0]=ve(n[0]),s[t](...n)):o}function Ln(e,t,n=[]){Ft(),zo();const s=ve(e)[t].apply(e,n);return Jo(),Nt(),s}const lr=Wo("__proto__,__v_isRef,__isVue"),Sl=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(St));function ar(e){St(e)||(e=String(e));const t=ve(this);return Ue(t,"has",e),t.hasOwnProperty(e)}class Cl{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const o=this._isReadonly,l=this._isShallow;if(n==="__v_isReactive")return!o;if(n==="__v_isReadonly")return o;if(n==="__v_isShallow")return l;if(n==="__v_raw")return s===(o?l?yr:Pl:l?El:Al).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const a=te(t);if(!o){let c;if(a&&(c=or[n]))return c;if(n==="hasOwnProperty")return ar}const r=Reflect.get(t,n,Ve(t)?t:s);if((St(n)?Sl.has(n):lr(n))||(o||Ue(t,"get",n),l))return r;if(Ve(r)){const c=a&&qo(n)?r:r.value;return o&&_e(c)?To(c):c}return _e(r)?o?To(r):yn(r):r}}class Tl extends Cl{constructor(t=!1){super(!1,t)}set(t,n,s,o){let l=t[n];const a=te(t)&&qo(n);if(!this._isShallow){const f=Ct(l);if(!at(s)&&!Ct(s)&&(l=ve(l),s=ve(s)),!a&&Ve(l)&&!Ve(s))return f||(l.value=s),!0}const r=a?Number(n)e,hs=e=>Reflect.getPrototypeOf(e);function fr(e,t,n){return function(...s){const o=this.__v_raw,l=ve(o),a=zt(l),r=e==="entries"||e===Symbol.iterator&&a,c=e==="keys"&&a,f=o[e](...s),u=n?Co:t?Gt:rt;return!t&&Ue(l,"iterate",c?So:cn),He(Object.create(f),{next(){const{value:h,done:w}=f.next();return w?{value:h,done:w}:{value:r?[u(h[0]),u(h[1])]:u(h),done:w}}})}}function vs(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function pr(e,t){const n={get(o){const l=this.__v_raw,a=ve(l),r=ve(o);e||($t(o,r)&&Ue(a,"get",o),Ue(a,"get",r));const{has:c}=hs(a),f=t?Co:e?Gt:rt;if(c.call(a,o))return f(l.get(o));if(c.call(a,r))return f(l.get(r));l!==a&&l.get(o)},get size(){const o=this.__v_raw;return!e&&Ue(ve(o),"iterate",cn),o.size},has(o){const l=this.__v_raw,a=ve(l),r=ve(o);return e||($t(o,r)&&Ue(a,"has",o),Ue(a,"has",r)),o===r?l.has(o):l.has(o)||l.has(r)},forEach(o,l){const a=this,r=a.__v_raw,c=ve(r),f=t?Co:e?Gt:rt;return!e&&Ue(c,"iterate",cn),r.forEach((u,h)=>o.call(l,f(u),f(h),a))}};return He(n,e?{add:vs("add"),set:vs("set"),delete:vs("delete"),clear:vs("clear")}:{add(o){const l=ve(this),a=hs(l),r=ve(o),c=!t&&!at(o)&&!Ct(o)?r:o;return a.has.call(l,c)||$t(o,c)&&a.has.call(l,o)||$t(r,c)&&a.has.call(l,r)||(l.add(c),It(l,"add",c,c)),this},set(o,l){!t&&!at(l)&&!Ct(l)&&(l=ve(l));const a=ve(this),{has:r,get:c}=hs(a);let f=r.call(a,o);f||(o=ve(o),f=r.call(a,o));const u=c.call(a,o);return a.set(o,l),f?$t(l,u)&&It(a,"set",o,l):It(a,"add",o,l),this},delete(o){const l=ve(this),{has:a,get:r}=hs(l);let c=a.call(l,o);c||(o=ve(o),c=a.call(l,o)),r&&r.call(l,o);const f=l.delete(o);return c&&It(l,"delete",o,void 0),f},clear(){const o=ve(this),l=o.size!==0,a=o.clear();return l&&It(o,"clear",void 0,void 0),a}}),["keys","values","entries",Symbol.iterator].forEach(o=>{n[o]=fr(o,e,t)}),n}function Zo(e,t){const n=pr(e,t);return(s,o,l)=>o==="__v_isReactive"?!e:o==="__v_isReadonly"?e:o==="__v_raw"?s:Reflect.get(me(n,o)&&o in s?n:s,o,l)}const hr={get:Zo(!1,!1)},vr={get:Zo(!1,!0)},mr={get:Zo(!0,!1)};const Al=new WeakMap,El=new WeakMap,Pl=new WeakMap,yr=new WeakMap;function gr(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function yn(e){return Ct(e)?e:Qo(e,!1,ur,hr,Al)}function br(e){return Qo(e,!1,dr,vr,El)}function To(e){return Qo(e,!0,cr,mr,Pl)}function Qo(e,t,n,s,o){if(!_e(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const l=o.get(e);if(l)return l;const a=gr(Va(e));if(a===0)return e;const r=new Proxy(e,a===2?s:n);return o.set(e,r),r}function Jt(e){return Ct(e)?Jt(e.__v_raw):!!(e&&e.__v_isReactive)}function Ct(e){return!!(e&&e.__v_isReadonly)}function at(e){return!!(e&&e.__v_isShallow)}function ei(e){return e?!!e.__v_raw:!1}function ve(e){const t=e&&e.__v_raw;return t?ve(t):e}function wr(e){return!me(e,"__v_skip")&&Object.isExtensible(e)&&pl(e,"__v_skip",!0),e}const rt=e=>_e(e)?yn(e):e,Gt=e=>_e(e)?To(e):e;function Ve(e){return e?e.__v_isRef===!0:!1}function de(e){return _r(e,!1)}function _r(e,t){return Ve(e)?e:new kr(e,t)}class kr{constructor(t,n){this.dep=new Xo,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:ve(t),this._value=n?t:rt(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||at(t)||Ct(t);t=s?t:ve(t),$t(t,n)&&(this._rawValue=t,this._value=s?t:rt(t),this.dep.trigger())}}function y(e){return Ve(e)?e.value:e}const $r={get:(e,t,n)=>t==="__v_raw"?e:y(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const o=e[t];return Ve(o)&&!Ve(n)?(o.value=n,!0):Reflect.set(e,t,n,s)}};function Ol(e){return Jt(e)?e:new Proxy(e,$r)}class xr{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Xo(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Xn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&Ce!==this)return bl(this,!0),!0}get value(){const t=this.dep.track();return kl(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Sr(e,t,n=!1){let s,o;return re(e)?s=e:(s=e.get,o=e.set),new xr(s,o,n)}const ms={},Ss=new WeakMap;let an;function Cr(e,t=!1,n=an){if(n){let s=Ss.get(n);s||Ss.set(n,s=[]),s.push(e)}}function Tr(e,t,n=xe){const{immediate:s,deep:o,once:l,scheduler:a,augmentJob:r,call:c}=n,f=C=>o?C:at(C)||o===!1||o===0?Mt(C,1):Mt(C);let u,h,w,$,D=!1,E=!1;if(Ve(e)?(h=()=>e.value,D=at(e)):Jt(e)?(h=()=>f(e),D=!0):te(e)?(E=!0,D=e.some(C=>Jt(C)||at(C)),h=()=>e.map(C=>{if(Ve(C))return C.value;if(Jt(C))return f(C);if(re(C))return c?c(C,2):C()})):re(e)?t?h=c?()=>c(e,2):e:h=()=>{if(w){Ft();try{w()}finally{Nt()}}const C=an;an=u;try{return c?c(e,3,[$]):e($)}finally{an=C}}:h=xt,t&&o){const C=h,V=o===!0?1/0:o;h=()=>Mt(C(),V)}const x=tr(),S=()=>{u.stop(),x&&x.active&&Ko(x.effects,u)};if(l&&t){const C=t;t=(...V)=>{const I=C(...V);return S(),I}}let L=E?new Array(e.length).fill(ms):ms;const J=C=>{if(!(!(u.flags&1)||!u.dirty&&!C))if(t){const V=u.run();if(C||o||D||(E?V.some((I,A)=>$t(I,L[A])):$t(V,L))){w&&w();const I=an;an=u;try{const A=[V,L===ms?void 0:E&&L[0]===ms?[]:L,$];L=V,c?c(t,3,A):t(...A)}finally{an=I}}}else u.run()};return r&&r(J),u=new yl(h),u.scheduler=a?()=>a(J,!1):J,$=C=>Cr(C,!1,u),w=u.onStop=()=>{const C=Ss.get(u);if(C){if(c)c(C,4);else for(const V of C)V();Ss.delete(u)}},t?s?J(!0):L=u.run():a?a(J.bind(null,!0),!0):u.run(),S.pause=u.pause.bind(u),S.resume=u.resume.bind(u),S.stop=S,S}function Mt(e,t=1/0,n){if(t<=0||!_e(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,Ve(e))Mt(e.value,t,n);else if(te(e))for(let s=0;s{Mt(s,t,n)});else if(fl(e)){for(const s in e)Mt(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&Mt(e[s],t,n)}return e}function us(e,t,n,s){try{return s?e(...s):e()}catch(o){Ws(o,t,n)}}function pt(e,t,n,s){if(re(e)){const o=us(e,t,n,s);return o&&cl(o)&&o.catch(l=>{Ws(l,t,n)}),o}if(te(e)){const o=[];for(let l=0;l>>1,o=qe[s],l=Qn(o);l=Qn(n)?qe.push(e):qe.splice(Er(t),0,e),e.flags|=1,Ml()}}function Ml(){Cs||(Cs=Il.then(Dl))}function Pr(e){if(!te(e))Wt&&e.id===-1?Wt.splice(kn+1,0,e):e.flags&1||(xn.push(e),e.flags|=1);else for(let t=0;tQn(n)-Qn(s));if(xn.length=0,Wt){for(let n=0;ne.id==null?e.flags&2?-1:1/0:e.id;function Dl(e){try{for(bt=0;bt{s._d&&Mi(-1);const l=Ts(t),a=fn.length;let r;try{r=e(...o)}finally{for(let c=fn.length;c>a;c--)aa();Ts(l),s._d&&Mi(1)}return r};return s._n=!0,s._c=!0,s._d=!0,s}function ye(e,t){if(Qe===null)return e;const n=Gs(Qe),s=e.dirs||(e.dirs=[]);for(let o=0;o1)return n&&re(t)?t.call(s&&s.proxy):t}}const Mr=Symbol.for("v-scx"),Lr=()=>ws(Mr);function dn(e,t,n){return Nl(e,t,n)}function Nl(e,t,n=xe){const{immediate:s,deep:o,flush:l,once:a}=n,r=He({},n),c=t&&s||!t&&l!=="post";let f;if(ns){if(l==="sync"){const $=Lr();f=$.__watcherHandles||($.__watcherHandles=[])}else if(!c){const $=()=>{};return $.stop=xt,$.resume=xt,$.pause=xt,$}}const u=je;r.call=($,D,E)=>pt($,u,D,E);let h=!1;l==="post"?r.scheduler=$=>{ze($,u&&u.suspense)}:l!=="sync"&&(h=!0,r.scheduler=($,D)=>{D?$():ti($)}),r.augmentJob=$=>{t&&($.flags|=4),h&&($.flags|=2,u&&($.id=u.uid,$.i=u))};const w=Tr(e,t,r);return ns&&(f?f.push(w):c&&w()),w}function Dr(e,t,n){const s=this.proxy,o=Pe(e)?e.includes(".")?Rl(s,e):()=>s[e]:e.bind(s,s);let l;re(t)?l=t:(l=t.handler,n=t);const a=cs(this),r=Nl(o,l.bind(s),n);return a(),r}function Rl(e,t){const n=t.split(".");return()=>{let s=e;for(let o=0;oe.__isTeleport,ro=Symbol("_leaveCb");function Nr(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==Rt){t=n;break}}return t}function Hl(e){if(!si(e))return qs(e.type)&&e.children?Nr(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&re(n.default))return n.default()}}function ni(e,t){if(e.shapeFlag&6&&e.component){e.transition=t;const n=e.component.subTree;ni(qs(n.type)&&Hl(n)||n,t)}else e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Te(e,t){return re(e)?He({name:e.name},t,{setup:e}):e}function Ul(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function ki(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const As=new WeakMap;function Yn(e,t,n,s,o=!1){if(te(e)){e.forEach((E,x)=>Yn(E,t&&(te(t)?t[x]:t),n,s,o));return}if(zn(s)&&!o){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Yn(e,t,n,s.component.subTree);return}const l=s.shapeFlag&4?Gs(s.component):s.el,a=o?null:l,{i:r,r:c}=e,f=t&&t.r,u=r.refs===xe?r.refs={}:r.refs,h=r.setupState,w=ve(h),$=h===xe?ul:E=>ki(u,E)?!1:me(w,E),D=(E,x)=>!(x&&ki(u,x));if(f!=null&&f!==c){if($i(t),Pe(f))u[f]=null,$(f)&&(h[f]=null);else if(Ve(f)){const E=t;D(f,E.k)&&(f.value=null),E.k&&(u[E.k]=null)}}if(re(c))us(c,r,12,[a,u]);else{const E=Pe(c),x=Ve(c);if(E||x){const S=()=>{if(e.f){const L=E?$(c)?h[c]:u[c]:D()||!e.k?c.value:u[e.k];if(o)te(L)&&Ko(L,l);else if(te(L))L.includes(l)||L.push(l);else if(E)u[c]=[l],$(c)&&(h[c]=u[c]);else{const J=[l];D(c,e.k)&&(c.value=J),e.k&&(u[e.k]=J)}}else E?(u[c]=a,$(c)&&(h[c]=a)):x&&(D(c,e.k)&&(c.value=a),e.k&&(u[e.k]=a))};if(a){const L=()=>{S(),As.delete(e)};L.id=-1,As.set(e,L),ze(L,n)}else $i(e),S()}}}function $i(e){const t=As.get(e);t&&(t.flags|=8,As.delete(e))}Bs().requestIdleCallback;Bs().cancelIdleCallback;const zn=e=>!!e.type.__asyncLoader,si=e=>e.type.__isKeepAlive;function Rr(e,t){jl(e,"a",t)}function Hr(e,t){jl(e,"da",t)}function jl(e,t,n=je){const s=e.__wdc||(e.__wdc=()=>{let o=n;for(;o;){if(o.isDeactivated)return;o=o.parent}return e()});if(Ys(t,s,n),n){let o=n.parent;for(;o&&o.parent;)si(o.parent.vnode)&&Ur(s,t,n,o),o=o.parent}}function Ur(e,t,n,s){const o=Ys(t,e,s,!0);Bl(()=>{Ko(s[t],o)},n)}function Ys(e,t,n=je,s=!1){if(n){const o=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...a)=>{Ft();const r=cs(n),c=pt(t,n,e,a);return r(),Nt(),c});return s?o.unshift(l):o.push(l),l}}const Ut=e=>(t,n=je)=>{(!ns||e==="sp")&&Ys(e,(...s)=>t(...s),n)},jr=Ut("bm"),wn=Ut("m"),Br=Ut("bu"),Vr=Ut("u"),On=Ut("bum"),Bl=Ut("um"),Wr=Ut("sp"),Kr=Ut("rtg"),qr=Ut("rtc");function Yr(e,t=je){Ys("ec",e,t)}const zr="components",Vl=Symbol.for("v-ndc");function Jr(e){return Pe(e)?Gr(zr,e,!1)||e:e||Vl}function Gr(e,t,n=!0,s=!1){const o=Qe||je;if(o){const l=o.type;{const r=Lu(l,!1);if(r&&(r===t||r===Ye(t)||r===Us(Ye(t))))return l}const a=xi(o[e]||l[e],t)||xi(o.appContext[e],t);return!a&&s?l:a}}function xi(e,t){return e&&(e[t]||e[Ye(t)]||e[Us(Ye(t))])}function ie(e,t,n,s){let o;const l=n,a=te(e);if(a||Pe(e)){const r=a&&Jt(e);let c=!1,f=!1;r&&(c=!at(e),f=Ct(e),e=Vs(e)),o=new Array(e.length);for(let u=0,h=e.length;ut(r,c,void 0,l));else{const r=Object.keys(e);o=new Array(r.length);for(let c=0,f=r.length;ce?da(e)?Gs(e):Ao(e.parent):null,Jn=He(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Ao(e.parent),$root:e=>Ao(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Kl(e),$forceUpdate:e=>e.f||(e.f=()=>{ti(e.update)}),$nextTick:e=>e.n||(e.n=Ks.bind(e.proxy)),$watch:e=>Dr.bind(e)}),uo=(e,t)=>e!==xe&&!e.__isScriptSetup&&me(e,t),Xr={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:o,props:l,accessCache:a,type:r,appContext:c}=e;if(t[0]!=="$"){const w=a[t];if(w!==void 0)switch(w){case 1:return s[t];case 2:return o[t];case 4:return n[t];case 3:return l[t]}else{if(uo(s,t))return a[t]=1,s[t];if(o!==xe&&me(o,t))return a[t]=2,o[t];if(me(l,t))return a[t]=3,l[t];if(n!==xe&&me(n,t))return a[t]=4,n[t];Eo&&(a[t]=0)}}const f=Jn[t];let u,h;if(f)return t==="$attrs"&&Ue(e.attrs,"get",""),f(e);if((u=r.__cssModules)&&(u=u[t]))return u;if(n!==xe&&me(n,t))return a[t]=4,n[t];if(h=c.config.globalProperties,me(h,t))return h[t]},set({_:e},t,n){const{data:s,setupState:o,ctx:l}=e;return uo(o,t)?(o[t]=n,!0):s!==xe&&me(s,t)?(s[t]=n,!0):me(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:o,props:l,type:a}},r){let c;return!!(n[r]||e!==xe&&r[0]!=="$"&&me(e,r)||uo(t,r)||me(l,r)||me(s,r)||me(Jn,r)||me(o.config.globalProperties,r)||(c=a.__cssModules)&&c[r])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:me(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Si(e){return te(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Eo=!0;function Zr(e){const t=Kl(e),n=e.proxy,s=e.ctx;Eo=!1,t.beforeCreate&&Ci(t.beforeCreate,e,"bc");const{data:o,computed:l,methods:a,watch:r,provide:c,inject:f,created:u,beforeMount:h,mounted:w,beforeUpdate:$,updated:D,activated:E,deactivated:x,beforeDestroy:S,beforeUnmount:L,destroyed:J,unmounted:C,render:V,renderTracked:I,renderTriggered:A,errorCaptured:b,serverPrefetch:T,expose:ne,inheritAttrs:Se,components:Oe,directives:G,filters:M}=t;if(f&&Qr(f,s,null),a)for(const oe in a){const fe=a[oe];re(fe)&&(s[oe]=fe.bind(n))}if(o){const oe=o.call(n,n);_e(oe)&&(e.data=yn(oe))}if(Eo=!0,l)for(const oe in l){const fe=l[oe],Z=re(fe)?fe.bind(n,n):re(fe.get)?fe.get.bind(n,n):xt,ue=!re(fe)&&re(fe.set)?fe.set.bind(n):xt,Me=k({get:Z,set:ue});Object.defineProperty(s,oe,{enumerable:!0,configurable:!0,get:()=>Me.value,set:Fe=>Me.value=Fe})}if(r)for(const oe in r)Wl(r[oe],s,n,oe);if(c){const oe=re(c)?c.call(n):c;Reflect.ownKeys(oe).forEach(fe=>{Ir(fe,oe[fe])})}u&&Ci(u,e,"c");function z(oe,fe){te(fe)?fe.forEach(Z=>oe(Z.bind(n))):fe&&oe(fe.bind(n))}if(z(jr,h),z(wn,w),z(Br,$),z(Vr,D),z(Rr,E),z(Hr,x),z(Yr,b),z(qr,I),z(Kr,A),z(On,L),z(Bl,C),z(Wr,T),te(ne))if(ne.length){const oe=e.exposed||(e.exposed={});ne.forEach(fe=>{Object.defineProperty(oe,fe,{get:()=>n[fe],set:Z=>n[fe]=Z,enumerable:!0})})}else e.exposed||(e.exposed={});V&&e.render===xt&&(e.render=V),Se!=null&&(e.inheritAttrs=Se),Oe&&(e.components=Oe),G&&(e.directives=G),T&&Ul(e)}function Qr(e,t,n=xt){te(e)&&(e=Po(e));for(const s in e){const o=e[s];let l;_e(o)?"default"in o?l=ws(o.from||s,o.default,!0):l=ws(o.from||s):l=ws(o),Ve(l)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>l.value,set:a=>l.value=a}):t[s]=l}}function Ci(e,t,n){pt(te(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function Wl(e,t,n,s){let o=s.includes(".")?Rl(n,s):()=>n[s];if(Pe(e)){const l=t[e];re(l)&&dn(o,l)}else if(re(e))dn(o,e.bind(n));else if(_e(e))if(te(e))e.forEach(l=>Wl(l,t,n,s));else{const l=re(e.handler)?e.handler.bind(n):t[e.handler];re(l)&&dn(o,l,e)}}function Kl(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:o,optionsCache:l,config:{optionMergeStrategies:a}}=e.appContext,r=l.get(t);let c;return r?c=r:!o.length&&!n&&!s?c=t:(c={},o.length&&o.forEach(f=>Es(c,f,a,!0)),Es(c,t,a)),_e(t)&&l.set(t,c),c}function Es(e,t,n,s=!1){const{mixins:o,extends:l}=t;l&&Es(e,l,n,!0),o&&o.forEach(a=>Es(e,a,n,!0));for(const a in t)if(!(s&&a==="expose")){const r=eu[a]||n&&n[a];e[a]=r?r(e[a],t[a]):t[a]}return e}const eu={data:Ti,props:Ai,emits:Ai,methods:Rn,computed:Rn,beforeCreate:We,created:We,beforeMount:We,mounted:We,beforeUpdate:We,updated:We,beforeDestroy:We,beforeUnmount:We,destroyed:We,unmounted:We,activated:We,deactivated:We,errorCaptured:We,serverPrefetch:We,components:Rn,directives:Rn,watch:nu,provide:Ti,inject:tu};function Ti(e,t){return t?e?function(){return He(re(e)?e.call(this,this):e,re(t)?t.call(this,this):t)}:t:e}function tu(e,t){return Rn(Po(e),Po(t))}function Po(e){if(te(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Ye(t)}Modifiers`]||e[`${tn(t)}Modifiers`];function lu(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||xe;let o=n;const l=t.startsWith("update:"),a=l&&iu(s,t.slice(7));a&&(a.trim&&(o=n.map(u=>Pe(u)?u.trim():u)),a.number&&(o=o.map(js)));let r,c=s[r=so(t)]||s[r=so(Ye(t))];!c&&l&&(c=s[r=so(tn(t))]),c&&pt(c,e,6,o);const f=s[r+"Once"];if(f){if(!e.emitted)e.emitted={};else if(e.emitted[r])return;e.emitted[r]=!0,pt(f,e,6,o)}}const au=new WeakMap;function Yl(e,t,n=!1){const s=n?au:t.emitsCache,o=s.get(e);if(o!==void 0)return o;const l=e.emits;let a={},r=!1;if(!re(e)){const c=f=>{const u=Yl(f,t,!0);u&&(r=!0,He(a,u))};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}return!l&&!r?(_e(e)&&s.set(e,null),null):(te(l)?l.forEach(c=>a[c]=null):He(a,l),_e(e)&&s.set(e,a),a)}function zs(e,t){return!e||!Ns(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),me(e,t[0].toLowerCase()+t.slice(1))||me(e,tn(t))||me(e,t))}function Ei(e){const{type:t,vnode:n,proxy:s,withProxy:o,propsOptions:[l],slots:a,attrs:r,emit:c,render:f,renderCache:u,props:h,data:w,setupState:$,ctx:D,inheritAttrs:E}=e,x=Ts(e);let S,L;try{if(n.shapeFlag&4){const C=o||s,V=C;S=kt(f.call(V,C,u,h,$,w,D)),L=r}else{const C=t;S=kt(C.length>1?C(h,{attrs:r,slots:a,emit:c}):C(h,null)),L=t.props?r:ru(r)}}catch(C){fn.length=0,Ws(C,e,1),S=we(Rt)}let J=S;if(L&&E!==!1){const C=Object.keys(L),{shapeFlag:V}=J;C.length&&V&7&&(l&&C.some(Rs)&&(L=uu(L,l)),J=Tn(J,L,!1,!0))}if(n.dirs&&(J=Tn(J,null,!1,!0),J.dirs=J.dirs?J.dirs.concat(n.dirs):n.dirs),n.transition){const C=qs(J.type)&&Hl(J)||J;ni(C,n.transition)}return S=J,Ts(x),S}const ru=e=>{let t;for(const n in e)(n==="class"||n==="style"||Ns(n))&&((t||(t={}))[n]=e[n]);return t},uu=(e,t)=>{const n={};for(const s in e)(!Rs(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function cu(e,t,n){const{props:s,children:o,component:l}=e,{props:a,children:r,patchFlag:c}=t,f=l.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return s?Pi(s,a,f):!!a;if(c&8){const u=t.dynamicProps;for(let h=0;hObject.create(Jl),Xl=e=>Object.getPrototypeOf(e)===Jl;function fu(e,t,n,s=!1){const o={},l=Gl();e.propsDefaults=Object.create(null),Zl(e,t,o,l);for(const a in e.propsOptions[0])a in o||(o[a]=void 0);n?e.props=s?o:br(o):e.type.props?e.props=o:e.props=l,e.attrs=l}function pu(e,t,n,s){const{props:o,attrs:l,vnode:{patchFlag:a}}=e,r=ve(o),[c]=e.propsOptions;let f=!1;if((s||a>0)&&!(a&16)){if(a&8){const u=e.vnode.dynamicProps;for(let h=0;h{c=!0;const[w,$]=Ql(h,t,!0);He(a,w),$&&r.push(...$)};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!l&&!c)return _e(e)&&s.set(e,un),un;if(te(l))for(let u=0;ue==="_"||e==="_ctx"||e==="$stable",ii=e=>te(e)?e.map(kt):[kt(e)],vu=(e,t,n)=>{if(t._n)return t;const s=Or((...o)=>ii(t(...o)),n);return s._c=!1,s},ea=(e,t,n)=>{const s=e._ctx;for(const o in e){if(oi(o))continue;const l=e[o];if(re(l))t[o]=vu(o,l,s);else if(l!=null){const a=ii(l);t[o]=()=>a}}},ta=(e,t)=>{const n=ii(t);e.slots.default=()=>n},na=(e,t,n)=>{for(const s in t)(n||!oi(s))&&(e[s]=t[s])},mu=(e,t,n)=>{const s=e.slots=Gl();if(e.vnode.shapeFlag&32){const o=t._;o?(na(s,t,n),n&&pl(s,"_",o,!0)):ea(t,s)}else t&&ta(e,t)},yu=(e,t,n)=>{const{vnode:s,slots:o}=e;let l=!0,a=xe;if(s.shapeFlag&32){const r=t._;r?n&&r===1?l=!1:na(o,t,n):(l=!t.$stable,ea(t,o)),a=t}else t&&(ta(e,t),a={default:1});if(l)for(const r in o)!oi(r)&&a[r]==null&&delete o[r]},ze=ku;function gu(e){return bu(e)}function bu(e,t){const n=Bs();n.__VUE__=!0;const{insert:s,remove:o,patchProp:l,createElement:a,createText:r,createComment:c,setText:f,setElementText:u,parentNode:h,nextSibling:w,setScopeId:$=xt,insertStaticContent:D}=e,E=(g,_,O,U=null,F=null,R=null,q=void 0,K=null,W=!!_.dynamicChildren)=>{if(g===_)return;g&&!Dn(g,_)&&(U=ce(g),Fe(g,F,R,!0),g=null),_.patchFlag===-2&&(W=!1,_.dynamicChildren=null),_.dynamicChildren&&g&&g.dynamicChildren&&g.dynamicChildren.hasOnce&&(_.dynamicChildren===un&&(_.dynamicChildren=[]),_.dynamicChildren.hasOnce=!0);const{type:N,ref:ee,shapeFlag:Y}=_;switch(N){case Js:x(g,_,O,U);break;case Rt:S(g,_,O,U);break;case _s:g==null&&L(_,O,U,q);break;case B:Oe(g,_,O,U,F,R,q,K,W);break;default:Y&1?V(g,_,O,U,F,R,q,K,W):Y&6?G(g,_,O,U,F,R,q,K,W):(Y&64||Y&128)&&N.process(g,_,O,U,F,R,q,K,W,sn)}ee!=null&&F?Yn(ee,g&&g.ref,R,_||g,!_):ee==null&&g&&g.ref!=null&&Yn(g.ref,null,R,g,!0)},x=(g,_,O,U)=>{if(g==null)s(_.el=r(_.children),O,U);else{const F=_.el=g.el;_.children!==g.children&&f(F,_.children)}},S=(g,_,O,U)=>{g==null?s(_.el=c(_.children||""),O,U):_.el=g.el},L=(g,_,O,U)=>{[g.el,g.anchor]=D(g.children,_,O,U,g.el,g.anchor)},J=({el:g,anchor:_},O,U)=>{let F;for(;g&&g!==_;)F=w(g),s(g,O,U),g=F;s(_,O,U)},C=({el:g,anchor:_})=>{let O;for(;g&&g!==_;)O=w(g),o(g),g=O;o(_)},V=(g,_,O,U,F,R,q,K,W)=>{if(_.type==="svg"?q="svg":_.type==="math"&&(q="mathml"),g==null)I(_,O,U,F,R,q,K,W);else{const N=g.el&&g.el._isVueCE?g.el:null;try{N&&N._beginPatch(),T(g,_,F,R,q,K,W)}finally{N&&N._endPatch()}}},I=(g,_,O,U,F,R,q,K)=>{let W,N;const{props:ee,shapeFlag:Y,transition:Q,dirs:ae}=g;if(W=g.el=a(g.type,R,ee&&ee.is,ee),Y&8?u(W,g.children):Y&16&&b(g.children,W,null,U,F,co(g,R),q,K),ae&&on(g,null,U,"created"),A(W,g,g.scopeId,q,U),ee){for(const $e in ee)$e!=="value"&&!Wn($e)&&l(W,$e,null,ee[$e],R,U);"value"in ee&&l(W,"value",null,ee.value,R),(N=ee.onVnodeBeforeMount)&>(N,U,g)}ae&&on(g,null,U,"beforeMount");const he=wu(F,Q);he&&Q.beforeEnter(W),s(W,_,O),((N=ee&&ee.onVnodeMounted)||he||ae)&&ze(()=>{N&>(N,U,g),he&&Q.enter(W),ae&&on(g,null,U,"mounted")},F)},A=(g,_,O,U,F)=>{if(O&&$(g,O),U)for(let R=0;R{for(let N=W;N{const K=_.el=g.el;let{patchFlag:W,dynamicChildren:N,dirs:ee}=_;W|=g.patchFlag&16;const Y=g.props||xe,Q=_.props||xe;let ae;if(O&&ln(O,!1),(ae=Q.onVnodeBeforeUpdate)&>(ae,O,_,g),ee&&on(_,g,O,"beforeUpdate"),O&&ln(O,!0),N&&(!g.dynamicChildren||g.dynamicChildren.length!==N.length)&&(W=0,q=!1,N=null),(Y.innerHTML&&Q.innerHTML==null||Y.textContent&&Q.textContent==null)&&u(K,""),N?ne(g.dynamicChildren,N,K,O,U,co(_,F),R):q||fe(g,_,K,null,O,U,co(_,F),R,!1),W>0){if(W&16)Se(K,Y,Q,O,F);else if(W&2&&Y.class!==Q.class&&l(K,"class",null,Q.class,F),W&4&&l(K,"style",Y.style,Q.style,F),W&8){const he=_.dynamicProps;for(let $e=0;$e{ae&>(ae,O,_,g),ee&&on(_,g,O,"updated")},U)},ne=(g,_,O,U,F,R,q)=>{for(let K=0;K<_.length;K++){const W=g[K],N=_[K],ee=W.el&&(W.type===B||!Dn(W,N)||W.shapeFlag&198)?h(W.el):O;E(W,N,ee,null,U,F,R,q,!0)}},Se=(g,_,O,U,F)=>{if(_!==O){if(_!==xe)for(const R in _)!Wn(R)&&!(R in O)&&l(g,R,_[R],null,F,U);for(const R in O){if(Wn(R))continue;const q=O[R],K=_[R];q!==K&&R!=="value"&&l(g,R,K,q,F,U)}"value"in O&&l(g,"value",_.value,O.value,F)}},Oe=(g,_,O,U,F,R,q,K,W)=>{const N=_.el=g?g.el:r(""),ee=_.anchor=g?g.anchor:r("");let{patchFlag:Y,dynamicChildren:Q,slotScopeIds:ae}=_;ae&&(K=K?K.concat(ae):ae),g==null?(s(N,O,U),s(ee,O,U),b(_.children||[],O,ee,F,R,q,K,W)):Y>0&&Y&64&&Q&&g.dynamicChildren&&g.dynamicChildren.length===Q.length?(ne(g.dynamicChildren,Q,O,F,R,q,K),(_.key!=null||F&&_===F.subTree)&&sa(g,_,!0)):fe(g,_,O,ee,F,R,q,K,W)},G=(g,_,O,U,F,R,q,K,W)=>{_.slotScopeIds=K,g==null?_.shapeFlag&512?F.ctx.activate(_,O,U,q,W):M(_,O,U,F,R,q,W):P(g,_,W)},M=(g,_,O,U,F,R,q)=>{const K=g.component=Au(g,U,F);if(si(g)&&(K.ctx.renderer=sn),Pu(K,!1,q),K.asyncDep){if(F&&F.registerDep(K,z,q),!g.el){const W=K.subTree=we(Rt);S(null,W,_,O),g.placeholder=W.el}}else z(K,g,_,O,F,R,q)},P=(g,_,O)=>{const U=_.component=g.component;if(cu(g,_,O))if(U.asyncDep&&!U.asyncResolved){_.el=g.el,oe(U,_,O);return}else U.next=_,U.update();else _.el=g.el,U.vnode=_},z=(g,_,O,U,F,R,q)=>{const K=()=>{if(g.isMounted){let{next:Y,bu:Q,u:ae,parent:he,vnode:$e}=g;{const mt=oa(g);if(mt){Y&&(Y.el=$e.el,oe(g,Y,q)),mt.asyncDep.then(()=>{ze(()=>{g.isUnmounted||N()},F)});return}}let be=Y,Ie;ln(g,!1),Y?(Y.el=$e.el,oe(g,Y,q)):Y=$e,Q&&bs(Q),(Ie=Y.props&&Y.props.onVnodeBeforeUpdate)&>(Ie,he,Y,$e),ln(g,!0);const Ne=Ei(g),vt=g.subTree;g.subTree=Ne,E(vt,Ne,h(vt.el),ce(vt),g,F,R),Y.el=Ne.el,be===null&&du(g,Ne.el),ae&&ze(ae,F),(Ie=Y.props&&Y.props.onVnodeUpdated)&&ze(()=>gt(Ie,he,Y,$e),F)}else{let Y;const{el:Q,props:ae}=_,{bm:he,m:$e,parent:be,root:Ie,type:Ne}=g,vt=zn(_);ln(g,!1),he&&bs(he),!vt&&(Y=ae&&ae.onVnodeBeforeMount)&>(Y,be,_),ln(g,!0);{Ie.ce&&Ie.ce._hasShadowRoot()&&Ie.ce._injectChildStyle(Ne,g.parent?g.parent.type:void 0);const mt=g.subTree=Ei(g);E(null,mt,O,U,g,F,R),_.el=mt.el}if($e&&ze($e,F),!vt&&(Y=ae&&ae.onVnodeMounted)){const mt=_;ze(()=>gt(Y,be,mt),F)}(_.shapeFlag&256||be&&zn(be.vnode)&&be.vnode.shapeFlag&256)&&g.a&&ze(g.a,F),g.isMounted=!0,_=O=U=null}};g.scope.on();const W=g.effect=new yl(K);g.scope.off();const N=g.update=W.run.bind(W),ee=g.job=W.runIfDirty.bind(W);ee.i=g,ee.id=g.uid,W.scheduler=()=>ti(ee),ln(g,!0),N()},oe=(g,_,O)=>{_.component=g;const U=g.vnode.props;g.vnode=_,g.next=null,pu(g,_.props,U,O),yu(g,_.children,O),Ft(),_i(g),Nt()},fe=(g,_,O,U,F,R,q,K,W=!1)=>{const N=g&&g.children,ee=g?g.shapeFlag:0,Y=_.children,{patchFlag:Q,shapeFlag:ae}=_;if(Q>0){if(Q&128){ue(N,Y,O,U,F,R,q,K,W);return}else if(Q&256){Z(N,Y,O,U,F,R,q,K,W);return}}ae&8?(ee&16&&H(N,F,R),Y!==N&&u(O,Y)):ee&16?ae&16?ue(N,Y,O,U,F,R,q,K,W):H(N,F,R,!0):(ee&8&&u(O,""),ae&16&&b(Y,O,U,F,R,q,K,W))},Z=(g,_,O,U,F,R,q,K,W)=>{g=g||un,_=_||un;const N=g.length,ee=_.length,Y=Math.min(N,ee);let Q;for(Q=0;Qee?H(g,F,R,!0,!1,Y):b(_,O,U,F,R,q,K,W,Y)},ue=(g,_,O,U,F,R,q,K,W)=>{let N=0;const ee=_.length;let Y=g.length-1,Q=ee-1;for(;N<=Y&&N<=Q;){const ae=g[N],he=_[N]=W?Pt(_[N]):kt(_[N]);if(Dn(ae,he))E(ae,he,O,null,F,R,q,K,W);else break;N++}for(;N<=Y&&N<=Q;){const ae=g[Y],he=_[Q]=W?Pt(_[Q]):kt(_[Q]);if(Dn(ae,he))E(ae,he,O,null,F,R,q,K,W);else break;Y--,Q--}if(N>Y){if(N<=Q){const ae=Q+1,he=aeQ)for(;N<=Y;)Fe(g[N],F,R,!0),N++;else{const ae=N,he=N,$e=new Map;for(N=he;N<=Q;N++){const Ze=_[N]=W?Pt(_[N]):kt(_[N]);Ze.key!=null&&$e.set(Ze.key,N)}let be,Ie=0;const Ne=Q-he+1;let vt=!1,mt=0;const Mn=new Array(Ne);for(N=0;N=Ne){Fe(Ze,F,R,!0);continue}let yt;if(Ze.key!=null)yt=$e.get(Ze.key);else for(be=he;be<=Q;be++)if(Mn[be-he]===0&&Dn(Ze,_[be])){yt=be;break}yt===void 0?Fe(Ze,F,R,!0):(Mn[yt-he]=N+1,yt>=mt?mt=yt:vt=!0,E(Ze,_[yt],O,null,F,R,q,K,W),Ie++)}const fi=vt?_u(Mn):un;for(be=fi.length-1,N=Ne-1;N>=0;N--){const Ze=he+N,yt=_[Ze],pi=_[Ze+1],hi=Ze+1{const{el:R,type:q,transition:K,children:W,shapeFlag:N}=g;if(N&6){Me(g.component.subTree,_,O,U);return}if(N&128){g.suspense.move(_,O,U);return}if(N&64){q.move(g,_,O,sn);return}if(q===B){s(R,_,O);for(let Y=0;YK.enter(R),F));else{const{leave:Y,delayLeave:Q,afterLeave:ae}=K,he=()=>{g.ctx.isUnmounted?o(R):s(R,_,O)},$e=()=>{const be=R._isLeaving||!!R[ro];R._isLeaving&&R[ro](!0),K.persisted&&!be?he():Y(R,()=>{he(),ae&&ae()})};Q?Q(R,he,$e):$e()}else s(R,_,O)},Fe=(g,_,O,U=!1,F=!1)=>{const{type:R,props:q,ref:K,children:W,dynamicChildren:N,shapeFlag:ee,patchFlag:Y,dirs:Q,cacheIndex:ae,memo:he}=g;if((Y===-2||N&&N.hasOnce)&&(F=!1),K!=null&&(Ft(),Yn(K,null,O,g,!0),Nt()),ae!=null&&(!g.ctx||g.ctx===_)&&(_.renderCache[ae]=void 0),ee&256){_.ctx.deactivate(g);return}const $e=ee&1&&Q,be=!zn(g);let Ie;if(be&&(Ie=q&&q.onVnodeBeforeUnmount)&>(Ie,_,g),ee&6)pe(g.component,O,U);else{if(ee&128){g.suspense.unmount(O,U);return}$e&&on(g,null,_,"beforeUnmount"),ee&64?g.type.remove(g,_,O,sn,U):N&&!N.hasOnce&&(R!==B||Y>0&&Y&64)?H(N,_,O,!1,!0):(R===B&&Y&384||!F&&ee&16)&&H(W,_,O),U&&nn(g)}const Ne=he!=null&&ae==null;(be&&(Ie=q&&q.onVnodeUnmounted)||$e||Ne)&&ze(()=>{Ie&>(Ie,_,g),$e&&on(g,null,_,"unmounted"),Ne&&(g.el=null)},O)},nn=g=>{const{type:_,el:O,anchor:U,transition:F}=g;if(_===B){ps(O,U);return}if(_===_s){C(g),F&&!F.persisted&&F.afterLeave&&F.afterLeave();return}const R=()=>{o(O),F&&!F.persisted&&F.afterLeave&&F.afterLeave()};if(g.shapeFlag&1&&F&&!F.persisted){const{leave:q,delayLeave:K}=F,W=()=>q(O,R);K?K(g.el,R,W):W()}else R()},ps=(g,_)=>{let O;for(;g!==_;)O=w(g),o(g),g=O;o(_)},pe=(g,_,O)=>{const{bum:U,scope:F,job:R,subTree:q,um:K,m:W,a:N}=g;Ii(W),Ii(N),U&&bs(U),F.stop(),R?(R.flags|=8,Fe(q,g,_,O)):g.vnode.el&&q&&(q.transition=g.vnode.transition,Fe(q,g,_,O)),K&&ze(K,_),ze(()=>{g.isUnmounted=!0},_)},H=(g,_,O,U=!1,F=!1,R=0)=>{for(let q=R;q{if(g.shapeFlag&6)return ce(g.component.subTree);if(g.shapeFlag&128)return g.suspense.next();const _=w(g.anchor||g.el),O=_&&_[Fr];return O?w(O):_};let ot=!1;const In=(g,_,O)=>{let U;g==null?_._vnode&&(Fe(_._vnode,null,null,!0),U=_._vnode.component):E(_._vnode||null,g,_,null,null,null,O),_._vnode=g,ot||(ot=!0,_i(U),Ll(),ot=!1)},sn={p:E,um:Fe,m:Me,r:nn,mt:M,mc:b,pc:fe,pbc:ne,n:ce,o:e};return{render:In,hydrate:void 0,createApp:ou(In)}}function co({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function ln({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function wu(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function sa(e,t,n=!1){const s=e.children,o=t.children;if(te(s)&&te(o))for(let l=0;l>1,e[n[r]]0&&(t[s]=n[l-1]),n[l]=s)}}for(l=n.length,a=n[l-1];l-- >0;)n[l]=a,a=t[a];return n}function oa(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:oa(t)}function Ii(e){if(e)for(let t=0;te.__isSuspense;function ku(e,t){t&&t.pendingBranch?te(e)?t.effects.push(...e):t.effects.push(e):Pr(e)}const B=Symbol.for("v-fgt"),Js=Symbol.for("v-txt"),Rt=Symbol.for("v-cmt"),_s=Symbol.for("v-stc"),fn=[];let et=null;function p(e=!1){fn.push(et=e?null:[])}function aa(){fn.pop(),et=fn[fn.length-1]||null}let es=1;function Mi(e,t=!1){es+=e,e<0&&et&&t&&(et.hasOnce=!0)}function ra(e){return e.dynamicChildren=es>0?et||un:null,aa(),es>0&&et&&et.push(e),e}function v(e,t,n,s,o,l){return ra(i(e,t,n,s,o,l,!0))}function Je(e,t,n,s,o){return ra(we(e,t,n,s,o,!0))}function ua(e){return e?e.__v_isVNode===!0:!1}function Dn(e,t){return e.type===t.type&&e.key===t.key}const ca=({key:e})=>e??null,ks=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?Pe(e)||Ve(e)||re(e)?{i:Qe,r:e,k:t,f:!!n}:e:null);function i(e,t=null,n=null,s=0,o=null,l=e===B?0:1,a=!1,r=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&ca(t),ref:t&&ks(t),scopeId:Fl,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:s,dynamicProps:o,dynamicChildren:null,appContext:null,ctx:Qe};return r?(Ps(c,n),l&128&&e.normalize(c)):n&&(c.shapeFlag|=Pe(n)?8:16),es>0&&!a&&et&&(c.patchFlag>0||l&6)&&c.patchFlag!==32&&et.push(c),c}const we=$u;function $u(e,t=null,n=null,s=0,o=null,l=!1){if((!e||e===Vl)&&(e=Rt),ua(e)){const r=Tn(e,t,!0);return n&&Ps(r,n),es>0&&!l&&et&&(r.shapeFlag&6?et[et.indexOf(e)]=r:et.push(r)),r.patchFlag=-2,r}if(Du(e)&&(e=e.__vccOpts),t){t=xu(t);let{class:r,style:c}=t;r&&!Pe(r)&&(t.class=le(r)),_e(c)&&(ei(c)&&!te(c)&&(c=He({},c)),t.style=Ae(c))}const a=Pe(e)?1:la(e)?128:qs(e)?64:_e(e)?4:re(e)?2:0;return i(e,t,n,s,o,a,l,!0)}function xu(e){return e?ei(e)||Xl(e)?He({},e):e:null}function Tn(e,t,n=!1,s=!1){const{props:o,ref:l,patchFlag:a,children:r,transition:c}=e,f=t?Su(o||{},t):o,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:f,key:f&&ca(f),ref:t&&t.ref?n&&l?te(l)?l.concat(ks(t)):[l,ks(t)]:ks(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:r,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==B?a===-1?16:a|16:a,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Tn(e.ssContent),ssFallback:e.ssFallback&&Tn(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce,cacheIndex:e.cacheIndex};return c&&s&&ni(u,c.clone(u)),u}function X(e=" ",t=0){return we(Js,null,e,t)}function $s(e,t){const n=we(_s,null,e);return n.staticCount=t,n}function j(e="",t=!1){return t?(p(),Je(Rt,null,e)):we(Rt,null,e)}function kt(e){return e==null||typeof e=="boolean"?we(Rt):te(e)?we(B,null,e.slice()):ua(e)?Pt(e):we(Js,null,String(e))}function Pt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Tn(e)}function Ps(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(te(t))n=16;else if(typeof t=="object")if(s&65){const o=t.default;o&&(o._c&&(o._d=!1),Ps(e,o()),o._c&&(o._d=!0));return}else{n=32;const o=t._;!o&&!Xl(t)?t._ctx=Qe:o===3&&Qe&&(Qe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(re(t)){if(s&65){Ps(e,{default:t});return}t={default:t,_ctx:Qe},n=32}else t=String(t),s&64?(n=16,t=[X(t)]):n=8;e.children=t,e.shapeFlag|=n}function Su(...e){const t={};for(let n=0;nje||Qe;let Os,ts;{const e=Bs(),t=(n,s)=>{let o;return(o=e[n])||(o=e[n]=[]),o.push(s),l=>{o.length>1?o.forEach(a=>a(l)):o[0](l)}};Os=t("__VUE_INSTANCE_SETTERS__",n=>je=n),ts=t("__VUE_SSR_SETTERS__",n=>ns=n)}const cs=e=>{const t=je;return Os(e),e.scope.on(),()=>{e.scope.off(),Os(t)}},Li=()=>{je&&je.scope.off(),Os(null)};function da(e){return e.vnode.shapeFlag&4}let ns=!1;function Pu(e,t=!1,n=!1){t&&ts(t);const{props:s,children:o}=e.vnode,l=da(e);fu(e,s,l,t),mu(e,o,n||t);const a=l?Ou(e,t):void 0;return t&&ts(!1),a}function Ou(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Xr);const{setup:s}=n;if(s){Ft();const o=e.setupContext=s.length>1?Mu(e):null,l=cs(e),a=us(s,e,0,[e.props,o]),r=cl(a);if(Nt(),l(),(r||e.sp)&&!zn(e)&&Ul(e),r){if(a.then(Li,Li),t)return a.then(c=>{ts(!0);try{Di(e,c,t)}finally{ts(!1)}}).catch(c=>{Ws(c,e,0)});e.asyncDep=a}else Di(e,a)}else fa(e)}function Di(e,t,n){re(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:_e(t)&&(e.setupState=Ol(t)),fa(e)}function fa(e,t,n){const s=e.type;e.render||(e.render=s.render||xt);{const o=cs(e);Ft();try{Zr(e)}finally{Nt(),o()}}}const Iu={get(e,t){return Ue(e,"get",""),e[t]}};function Mu(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Iu),slots:e.slots,emit:e.emit,expose:t}}function Gs(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Ol(wr(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Jn)return Jn[n](e)},has(t,n){return n in t||n in Jn}})):e.proxy}function Lu(e,t=!0){return re(e)?e.displayName||e.name:e.name||t&&e.__name}function Du(e){return re(e)&&"__vccOpts"in e}const k=(e,t)=>Sr(e,t,ns),Fu="3.5.43";let Io;const Fi=typeof window<"u"&&window.trustedTypes;if(Fi)try{Io=Fi.createPolicy("vue",{createHTML:e=>e})}catch{}const pa=Io?e=>Io.createHTML(e):e=>e,Nu="http://www.w3.org/2000/svg",Ru="http://www.w3.org/1998/Math/MathML",Et=typeof document<"u"?document:null,Ni=Et&&Et.createElement("template"),Hu={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const o=t==="svg"?Et.createElementNS(Nu,e):t==="mathml"?Et.createElementNS(Ru,e):n?Et.createElement(e,{is:n}):Et.createElement(e);return e==="select"&&s&&s.multiple!=null&&o.setAttribute("multiple",s.multiple),o},createText:e=>Et.createTextNode(e),createComment:e=>Et.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Et.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,o,l){const a=n?n.previousSibling:t.lastChild;if(o&&(o===l||o.nextSibling))for(;t.insertBefore(o.cloneNode(!0),n),!(o===l||!(o=o.nextSibling)););else{Ni.innerHTML=pa(s==="svg"?``:s==="mathml"?``:e);const r=Ni.content;if(s==="svg"||s==="mathml"){const c=r.firstChild;for(;c.firstChild;)r.appendChild(c.firstChild);r.removeChild(c)}t.insertBefore(r,n)}return[a?a.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Uu=Symbol("_vtc");function ju(e,t,n){const s=e[Uu];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Ri=Symbol("_vod"),Bu=Symbol("_vsh"),Vu=Symbol(""),Wu=/(?:^|;)\s*display\s*:/;function Ku(e,t,n){const s=e.style,o=Pe(n);let l=!1;if(n&&!o){if(t)if(Pe(t))for(const a of t.split(";")){const r=a.slice(0,a.indexOf(":")).trim();n[r]==null&&Hn(s,r,"")}else for(const a in t)n[a]==null&&Hn(s,a,"");for(const a in n){a==="display"&&(l=!0);const r=n[a];r!=null?Yu(e,a,!Pe(t)&&t?t[a]:void 0,r)||Hn(s,a,r):Hn(s,a,"")}}else if(o){if(t!==n){const a=s[Vu];a&&(n+=";"+a),s.cssText=n,l=Wu.test(n)}}else t&&e.removeAttribute("style");Ri in e&&(e[Ri]=l?s.display:"",e[Bu]&&(s.display="none"))}const ys=/\s*!important$/;function Hn(e,t,n){if(te(n))n.forEach(s=>Hn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))ys.test(n)?e.setProperty(t,n.replace(ys,""),"important"):e.setProperty(t,n);else{const s=qu(e,t);ys.test(n)?e.setProperty(tn(s),n.replace(ys,""),"important"):e[s]=n}}const Hi=["Webkit","Moz","ms"],fo={};function qu(e,t){const n=fo[t];if(n)return n;let s=Ye(t);if(s!=="filter"&&s in e)return fo[t]=s;s=Us(s);for(let o=0;opo||(Qu.then(()=>po=0),po=Date.now());function tc(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const o=n.value;if(te(o)){const l=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{l.call(s),s._stopped=!0};const a=o.slice(),r=[s];for(let c=0;ce.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,nc=(e,t,n,s,o,l)=>{const a=o==="svg";t==="class"?ju(e,s,a):t==="style"?Ku(e,n,s):Ns(t)?Rs(t)||Ju(e,t,n,s,l):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):sc(e,t,s,a))?(Bi(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&ji(e,t,s,a,l,t!=="value")):e._isVueCE&&(oc(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Pe(s)))?Bi(e,Ye(t),s,l,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),ji(e,t,s,a))};function sc(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Wi(t)&&re(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const o=e.tagName;if(o==="IMG"||o==="VIDEO"||o==="CANVAS"||o==="SOURCE")return!1}return Wi(t)&&Pe(n)?!1:t in e}function oc(e,t){const n=e._def.props;if(!n)return!1;const s=Ye(t);return Array.isArray(n)?n.some(o=>Ye(o)===s):Object.keys(n).some(o=>Ye(o)===s)}const Xt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return te(t)?n=>bs(t,n):t};function ic(e){e.target.composing=!0}function Ki(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const tt=Symbol("_assign"),gs=Symbol("_initialValue");function ho(e,t,n){return t&&(e=e.trim()),n&&(e=js(e)),e}const De={created(e,{modifiers:{lazy:t,trim:n,number:s}},o){e.parentNode&&(e.type==="text"?e[gs]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[gs]=e.defaultValue.replace(/\r\n?/g,`
-`))),e[tt]=Xt(o);const l=s||o.props&&o.props.type==="number";Lt(e,t?"change":"input",a=>{a.target.composing||e[tt](ho(e.value,n,l))}),(n||l)&&Lt(e,"change",()=>{e.value=ho(e.value,n,l)}),t||(Lt(e,"compositionstart",ic),Lt(e,"compositionend",Ki),Lt(e,"change",Ki))},mounted(e,{value:t,modifiers:{trim:n,number:s}}){const o=t??"",l=e[gs];delete e[gs],l!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==l?e[tt](ho(e.value,n,s)):e.value=o},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:o,number:l}},a){if(e[tt]=Xt(a),e.composing)return;const r=(l||e.type==="number")&&!/^0\d/.test(e.value)?js(e.value):e.value,c=t??"";if(r===c)return;const f=e.getRootNode();(f instanceof Document||f instanceof ShadowRoot)&&f.activeElement===e&&e.type!=="range"&&(s&&t===n||o&&e.value.trim()===c)||(e.value=c)}},lc={deep:!0,created(e,t,n){e[tt]=Xt(n),Lt(e,"change",()=>{const s=e._modelValue,o=An(e),l=e.checked,a=e[tt];if(te(s)){const r=Yo(s,o),c=r!==-1;if(l&&!c)a(s.concat(o));else if(!l&&c){const f=[...s];f.splice(r,1),a(f)}}else if(Dt(s)){const r=new Set(s);l?r.add(o):r.delete(o),a(r)}else a(ha(e,l))})},mounted:qi,beforeUpdate(e,t,n){e[tt]=Xt(n),qi(e,t,n)}};function qi(e,{value:t,oldValue:n},s){e._modelValue=t;let o;if(te(t))o=Yo(t,s.props.value)>-1;else if(Dt(t))o=t.has(s.props.value);else{if(t===n)return;o=ft(t,ha(e,!0))}e.checked!==o&&(e.checked=o)}const Yi={created(e,{value:t},n){e.checked=ft(t,n.props.value),e[tt]=Xt(n),Lt(e,"change",()=>{e[tt](An(e))})},beforeUpdate(e,{value:t,oldValue:n},s){e[tt]=Xt(s),t!==n&&(e.checked=ft(t,s.props.value))}},pn={deep:!0,created(e,{value:t,modifiers:{number:n}},s){e._modelValue=t,Lt(e,"change",()=>{const o=Array.prototype.filter.call(e.options,c=>c.selected).map(c=>n?js(An(c)):An(c)),l=e.multiple,a=l?Dt(e._modelValue)?new Set(o):o:o[0],r=e._pendingValue=[l,l?te(a)?o.slice():o:a];try{e[tt](a)}finally{Ks(()=>{e._pendingValue===r&&(e._pendingValue=void 0)})}}),e[tt]=Xt(s)},mounted(e,{value:t}){zi(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[tt]=Xt(n)},updated(e,{value:t}){const n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!ac(t,n[1],n[0]))&&zi(e,t)}};function ac(e,t,n){if(!n||te(e))return ft(e,t);if(Dt(e)){if(e.size!==t.length)return!1;for(const s of t)if(!e.has(s))return!1;return!0}return!1}function zi(e,t){const n=e.multiple,s=te(t);if(!(n&&!s&&!Dt(t))){for(let o=0,l=e.options.length;oString(f)===String(r)):a.selected=Yo(t,r)>-1}else a.selected=t.has(r);else if(ft(An(a),t)){e.selectedIndex!==o&&(e.selectedIndex=o);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function An(e){return"_value"in e?e._value:e.value}function ha(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const rc=["ctrl","shift","alt","meta"],uc={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>rc.some(n=>e[`${n}Key`]&&!t.includes(n))},ut=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((o,...l)=>{for(let a=0;a{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=(o=>{if(!("key"in o))return;const l=tn(o.key);if(t.some(a=>a===l||cc[a]===l))return e(o)}))},dc=He({patchProp:nc},Hu);let Ji;function fc(){return Ji||(Ji=gu(dc))}const pc=((...e)=>{const t=fc().createApp(...e),{mount:n}=t;return t.mount=s=>{const o=vc(s);if(!o)return;const l=t._component;!re(l)&&!l.render&&!l.template&&(l.template=o.innerHTML),o.nodeType===1&&(o.textContent="");const a=n(o,!1,hc(o));return o instanceof Element&&(o.removeAttribute("v-cloak"),o.setAttribute("data-v-app","")),a},t});function hc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function vc(e){return Pe(e)?document.querySelector(e):e}const mc=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],yc=["Su","Mo","Tu","We","Th","Fr","Sa"],ma={clock:"clock-outline",analog:"clock-outline",date:"calendar"},Ke=e=>JSON.stringify(e),ge=e=>String.fromCodePoint(parseInt(e,16));function li(e,t=new Date){let n=t.getHours();return e||(n=n%12||12),`${String(n).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}const ai=(e=new Date)=>`${yc[e.getDay()]} ${e.getDate()} ${mc[e.getMonth()]}`;function gc(e,t=Math.floor(Date.now()/1e3)){const n=t-e,s=Math.abs(n),o=l=>Math.floor(s/l);return n<0?s<3600?`In ${Math.max(1,o(60))} min`:s<86400?o(3600)===1?"In 1 hour":`In ${o(3600)} hours`:s<172800?"Tomorrow":`In ${o(86400)} days`:s<60?"Just now":s<3600?`${o(60)} min ago`:s<86400?o(3600)===1?"1 hour ago":`${o(3600)} hours ago`:s<172800?"Yesterday":s<604800?`${o(86400)} days ago`:s<2592e3?o(604800)===1?"1 week ago":`${o(604800)} weeks ago`:s<31536e3?o(2592e3)===1?"1 month ago":`${o(2592e3)} months ago`:o(31536e3)===1?"1 year ago":`${o(31536e3)} years ago`}const bc={guition:{width:448,top:36,name:27,text:21,icon:26},cyd:{width:298,top:24,name:18,text:14,icon:18}};let Fn;const Mo=new Map,wc=()=>Mo.clear();function rn(e,t){const n=`${t}|${e}`;let s=Mo.get(n);if(!s){if(Fn===void 0&&(Fn=document.createElement("canvas").getContext("2d")),Fn){Fn.font=t;const o=Fn.measureText(e);s={left:-o.actualBoundingBoxLeft,right:o.actualBoundingBoxRight,top:-o.actualBoundingBoxAscent,bottom:o.actualBoundingBoxDescent,advance:o.width}}else{const o=Number(/(\d+(?:\.\d+)?)px/.exec(t)?.[1]||14),l=[...e].length*o*.55;s={left:0,right:l,top:-o*.72,bottom:o*.02,advance:l}}Mo.set(n,s)}return s}const _c=e=>({name:`500 ${e.name}px "Bar Roboto"`,text:`400 ${e.text}px "Bar Roboto"`,icon:`${e.icon}px "Tile Icons"`});function kc(e){return{icon:Math.max(2,Math.floor((e*4+5)/10)),item:Math.max(6,Math.floor((e*125+50)/100)),name:Math.max(8,Math.floor((e*16+5)/10))}}function ya(e,t,n,s){const o=_c(t),l=rn("0",o.text),a=Math.round(l.bottom-l.top),r=kc(a),c=rn(String.fromCodePoint(983376),o.icon),f=Math.round(c.bottom-c.top),u=e.map((C,V)=>{const I=s(C),A={index:V,item:C,view:I,shown:I.shown,width:0};return I.analog?(A.dial=f,A.width=f,A):(I.icon&&(A.icon={glyph:ge(I.icon),ink:rn(ge(I.icon),o.icon)},A.width+=A.icon.ink.right-A.icon.ink.left),I.text&&(A.text={value:I.text,ink:rn(I.text,o.text)},A.width+=(A.icon?r.icon:0)+A.text.ink.right-A.text.ink.left),A)}),h=u.filter(C=>C.shown),w=rn(n,o.name).advance,$=Math.min(w,Math.floor(t.width*35/100)),D=C=>C.reduce((V,I)=>V+I.width,0)+Math.max(0,C.length-1)*r.item;let E=0;for(;Et.width;)E++;const x=h.slice(E),S=new Set(h.slice(0,E).map(C=>C.index));let L=t.width-D(x);for(const C of x)C.x=L,L+=C.width+r.item;const J=x.length?x[0].x-r.name:t.width;return{metrics:t,fonts:o,cap:a,zero:l,gaps:r,parts:u,placed:x,dropped:S,nameText:n,natural:w,nameRoom:J}}function $c(e,t,n){if(rn(e,t).advance<=n)return e;const s=[...e];for(;s.length&&rn(s.join("")+"...",t).advance>n;)s.pop();return s.join("")+"..."}function xc(e){"fonts"in document&&Promise.all([document.fonts.load('500 27px "Bar Roboto"',"Studio 0"),document.fonts.load('400 21px "Bar Roboto"',"Away 0"),document.fonts.load('26px "Tile Icons"',String.fromCodePoint(983376))]).then(()=>{wc(),e()}).catch(()=>{})}let ga="";function Sc(e){ga=e||""}async function ri(e,t={}){const n=await fetch(`api/${e}`,{...t,headers:{"Content-Type":"application/json","X-Screen-CSRF":ga,...t.headers}});if(!n.ok){let s="That didn't work. Refresh the page and try again.";try{s=(await n.json()).error||s}catch{}throw new Error(s)}return n}const ht=async e=>(await ri(e)).json(),st=async(e,t,n,s={})=>{const l=await(await ri(e,{method:t,body:n===void 0?void 0:JSON.stringify(n),...s})).text();return l?JSON.parse(l):null},Le=6,gn=8,ss=gn*Le,Cc=["single","wide","full"],Zt=e=>e===!0?"wide":e===!1?"single":e,Xs=e=>/^screen\.page_[1-8]$/.test(e)?Number(e.slice(-1)):0,Ge=e=>Cc.includes(e.options?.size)?e.options.size:"single",Lo=e=>Ge(e)!=="single",ba=e=>Ge(e)==="full",wa=e=>e-e%Le,_a=e=>e-e%2,En=e=>Math.floor(e/Le),ui=e=>Zt(e)==="full"?Le:Zt(e)==="wide"?2:1,hn=(e,t)=>Zt(t)==="full"?Array.from({length:Le},(n,s)=>wa(e)+s):Zt(t)==="wide"?[e,e+1]:[e],Gi=(e,t)=>Zt(t)==="full"?wa(e):Zt(t)==="wide"?_a(e):e,nt=e=>e.tiles.map(t=>({tile:t,slot:t.slot}));function ka(e){let t=0;return e.map(n=>{const s=Ge(n);s==="full"&&t%Le?t+=Le-t%Le:s==="wide"&&t%2===1&&t++;const o=t;return t+=ui(s),o})}function Tc(e){const t=ka(e);return e.some((n,s)=>n.slot!==t[s])}function Zs(e){if(e.tiles.some(t=>!Number.isInteger(t.slot))){const t=ka(e.tiles);e.tiles.forEach((n,s)=>n.slot=t[s])}e.tiles.sort((t,n)=>t.slot-n.slot)}function Gn(e){const t=new Set;for(const{tile:n,slot:s}of e)for(const o of hn(s,Ge(n)))t.add(o);return t}const os=(e,t,n)=>Number.isInteger(t)&&t>=0&&t!e.has(s));function Do(e,t,n=0){for(let s=n;so.includes(f))?r.push(c):a.push({tile:c,slot:c.slot}));for(const c of r){const f=Ge(c),u=Gn(a);let h=l.map(w=>Gi(w,f)).find(w=>os(u,w,f));if(h===void 0&&(h=$a(u,f,c.slot)),h<0)return null;a.push({tile:c,slot:h})}return a.sort((c,f)=>c.slot-f.slot)}function jt(e,t=1){const n=Math.max(0,...e.map(({tile:s,slot:o})=>o+ui(Ge(s))));return Math.min(gn,Math.max(1,Math.ceil(n/Le),t||1))}function Ac(e){const t=e.split(".")[0];return t==="sun"?{options:{display:"sunpath",size:"wide"}}:t==="weather"?{options:{display:"forecast",size:"wide"}}:Xs(e)?{}:t==="screen"?{options:{display:"digital",size:"wide"}}:{}}const Sa=e=>({entity:e,name:"",slot:-1,...Ac(e)});function Ca(e,t){const n=e.entity.split(".")[0],s=t.controls?.[n],o=e.options||{};if(!s||!["wide","full"].includes(o.size)||(o.display||"standard")!=="standard"||o.inline==="slider")return null;const l=o.controls??(o.size==="full"?"none":s.default);return l==="none"?null:l}function Ec(e,t){const n=Ca(e,t);return n?t.controls?.[e.entity.split(".")[0]]?.choices.find(s=>s.key===n)?.label.toLocaleLowerCase()||n:"none"}const Fo=e=>(/^(\d+)\.(\d+)\.(\d+)$/.exec(e||"")||[]).slice(1).map(Number);function Qt(e,t){const[n,s]=[Fo(e),Fo(t)];if(n.length!==3||s.length!==3)return!1;for(let o=0;o<3;o++)if(n[o]!==s[o])return n[o]>s[o];return!0}const Pc=(e,t,n,s)=>Qt(e,`${t}.${n}.${s}`),Oc=ss;function Ic(e){return Fo(e).length!==3?10:Qt(e,"0.2.62")?Oc:Qt(e,"0.2.7")?20:10}const Ta={standard:"standard",watch:"large value",forecast:"weather forecast",graph:"graph",digital:"digital clock",analog:"analog clock",sunpath:"sun path"},Mc={single:"normal",wide:"double",full:"full page"},Xi=["light","switch","input_boolean","fan","media_player","climate"],Lc=["light","fan","cover","number","input_number","media_player"],Dc={light:["Light","☀","#ad7600","#fff3d3"],climate:["Climate","❄","#c86620","#ffebdc"],vacuum:["Vacuum","◉","#008577","#def3ed"],fan:["Fan","✣","#008aab","#def5fa"],cover:["Cover","▤","#8053af","#eee5f8"],media_player:["Media","▶","#007cad","#def2fc"],sensor:["Sensor","⌁","#3476b1","#e5effa"],binary_sensor:["Status","◈","#ad7600","#fff3d3"],switch:["Switch","⏻","#ad7600","#fff3d3"],input_boolean:["Switch","⏻","#ad7600","#fff3d3"],scene:["Scene","✦","#8053af","#eee5f8"],script:["Script","▷","#8053af","#eee5f8"],weather:["Weather","☁","#007cad","#def2fc"],number:["Value","±","#008577","#def3ed"],input_number:["Value","±","#008577","#def3ed"],select:["Select","≡","#5862af","#eaecfa"],input_select:["Select","≡","#5862af","#eaecfa"],button:["Action","↗","#5862af","#eaecfa"],input_button:["Action","↗","#5862af","#eaecfa"],screen:["Screen","◷","#25282c","#e9ecf1"],sun:["Sun","☼","#c86620","#ffebdc"],timer:["Timer","⏱","#008577","#def3ed"],person:["Person","☺","#2f7d32","#e1f2e2"],camera:["Camera","◧","#3d4a57","#e6ebf0"],image:["Image","◧","#3d4a57","#e6ebf0"]},wt=e=>Dc[e.split(".")[0]]||["Entity","◇","#637184","#edf0f4"],d=yn({inventory:{screens:[],entities:[]},connected:!1,reachable:!0,selected:null,layout:null,dirty:!1,busy:!1,saved:0,tab:"layout",selectedTile:null,inspector:null,iconPickerOpen:!1,actionPickerOpen:!1,actionSearch:"",insertAt:-1,filter:"",search:"",capabilities:{},entityActions:{},topbarPreviews:{},topbarAdded:null,topbarOverflow:[],settingEdits:{},settingPending:!1,updating:[],toast:null,now:Date.now(),fontsVersion:0,route:location.hash,overrideProfile:null,overrideFriendly:"",drag:{active:!1,moving:null,preview:null},menuOpen:!1,liveStates:{},room:"",hidePlaced:!1,palette:!1,firmwareJob:null}),Tt=k(()=>d.inventory.screens.find(e=>e.id===d.selected)),Aa=k(()=>Tt.value?.firmware||""),Kt=(e,t,n)=>Pc(Aa.value,e,t,n),vn=k(()=>Ic(Aa.value)),ci=k(()=>Tt.value?.board==="guition"),Ea=k(()=>bc[ci.value?"guition":"cyd"]),vo=k(()=>d.selectedTile&&d.layout?d.layout.tiles.find(e=>e.entity===d.selectedTile):void 0);let Zi=0;function ke(e,t){d.toast={message:e,action:t},clearTimeout(Zi),Zi=window.setTimeout(()=>d.toast=null,t?8e3:5e3)}function Fc(){d.toast=null}async function ct(e,t,n="API key"){try{if(!navigator.clipboard||!window.isSecureContext)throw new Error;await navigator.clipboard.writeText(e),ke(`${n} copied.`)}catch{if(t){const s=document.createRange();s.selectNodeContents(t);const o=window.getSelection();o?.removeAllRanges(),o?.addRange(s)}ke(document.execCommand("copy")?`${n} copied.`:`${n} is selected. Copy with Ctrl+C or Command+C.`)}}function Is(){const e="/config/integrations/dashboard";try{window.top.location.assign(e)}catch{window.open(e,"_blank")}}const Nc=["","#settings","#new-screen","#firmware","#alerts","#override"],lt=k(()=>Nc.includes(d.route)?d.route:"");function Ee(e){if(location.hash===e){d.route=e;return}location.hash=e}window.addEventListener("hashchange",()=>{d.route=location.hash,window.scrollTo(0,0)});function ds(e){return d.inventory.entities.find(t=>t.id===e)?.name||d.inventory.builtin?.find(t=>t.id===e)?.name||e}let mo={source:null,byName:{}};function fs(e){return mo.source!==d.inventory.icons&&(mo={source:d.inventory.icons,byName:Object.fromEntries((d.inventory.icons?.groups||[]).flatMap(t=>t.icons.map(n=>[n.name,n])))}),e?mo.byName[e]:void 0}function en(e){const t=d.inventory.icons;if(!t)return"F0335";const n=d.inventory.entities.find(o=>o.id===e),s=e.split(".")[0];return t.builtin?.[e]?t.builtin[e]:n?.icon?n.icon:s==="weather"?t.weather[n?.state||""]||t.weather.partlycloudy:s==="sun"?t.sun[n?.state||""]||t.sun.below_horizon:t.defaults[s]||t.fallback}const Un=e=>fs(e.options?.icon)?.cp||en(e.entity),yo=new Set;async function Qs(e){const t=[...new Set(e)].filter(n=>!yo.has(n)&&!n.startsWith("screen."));if(t.length){t.forEach(n=>yo.add(n));try{for(let n=0;n`entity=${encodeURIComponent(o)}`).join("&");Object.assign(d.capabilities,(await ht(`capabilities?${s}`)).capabilities||{})}}catch{t.forEach(n=>yo.delete(n))}}}const go=new Set;async function Rc(e){if(!go.has(e)){go.add(e);try{d.entityActions[e]=(await ht(`entity-actions?entity=${encodeURIComponent(e)}`)).actions}catch{go.delete(e)}}}let bo=!1;async function eo(){const e=[...new Set((d.layout?.tiles||[]).map(t=>t.entity).filter(t=>!t.startsWith("screen.")))];if(!(!e.length||bo)){bo=!0;try{for(let t=0;t`entity=${encodeURIComponent(s)}`).join("&");Object.assign(d.liveStates,(await ht(`states?${n}`)).states||{})}}catch{}finally{bo=!1}}}function Hc(e){const t=d.liveStates[e];if(t)return t;const n=d.inventory.entities.find(s=>s.id===e);return n?.state?{state:n.state,word:null,a:{}}:null}function Xe(){d.dirty=!0,d.saved=0}function Pa(e){if(e!==d.selected&&d.dirty&&!confirm("You have unsaved changes. Open a different screen anyway?"))return;e!==d.selected&&(ls(),d.settingEdits={}),d.selected=e,d.selectedTile=null,d.inspector=null,d.tab="layout",d.menuOpen=!1;const t=d.inventory.screens.find(s=>s.id===e);if(!t){d.layout=null;return}const n=JSON.parse(JSON.stringify(t.layout));Zs(n),n.pages=jt(nt(n),n.pages),d.layout=n,d.insertAt=-1,d.dirty=!1,d.saved=0,no(0),Qs(n.tiles.map(s=>s.entity)),eo(),Ee("")}function Uc(e){const t=d.layout,n=t.tiles.map(o=>`${o.entity}@${o.slot}`).join();for(const{tile:o,slot:l}of e)o.slot=l,t.tiles.includes(o)||t.tiles.push(o);Zs(t),t.pages=jt(nt(t),t.pages);const s=t.tiles.map(o=>`${o.entity}@${o.slot}`).join()!==n;return s&&Xe(),s}function to(e,t){if(!d.layout)return!1;Qs([e.entity]);const n=xa(d.layout.tiles,e,t),s=n?Uc(n):!1;return s&&!d.liveStates[e.entity]&&eo(),s}function Oa(e){const t=d.layout;if(!t||t.tiles.some(o=>o.entity===e)||t.tiles.length>=vn.value)return;const n=Sa(e),s=d.insertAt>=0?d.insertAt:Do(Gn(nt(t)),Ge(n));d.insertAt=-1,s>=0&&to(n,s)&&No(e)}function Ia(e){const t=d.layout;if(!t)return;const n=t.tiles.indexOf(e);n<0||(t.tiles.splice(n,1),d.selectedTile===e.entity&&Be(),Xe(),t.pages=jt(nt(t),t.pages),ke(`${e.name||ds(e.entity)} removed`,{label:"Undo",run:()=>to(e,e.slot)}))}function Qi(){const e=d.layout;e&&(e.pages=Math.min(gn,jt(nt(e),e.pages)+1),Xe())}function jc(e){const t=d.layout;if(t){for(const n of t.tiles)En(n.slot)>e&&(n.slot-=Le);t.pages=Math.max(1,jt(nt(t),t.pages)-1),Xe()}}function Bc(){const e=d.layout;if(!e)return 1;const t=d.drag.preview||nt(e),n=jt(t,e.pages);return d.drag.active&&nf.key!=="none"&&o.controls.includes(f.key))?.key||"none"),Xe();const c=d.layout;if(c){if(ba(e)&&a!=="full"){const f=En(e.slot),u=c.tiles.filter($=>$!==e&&En($.slot)===f),h=Gn(nt(c).filter($=>$.tile!==e&&!u.includes($.tile))),w=[];for(const $ of u){const D=Do(h,Ge($),(f+1)*Le);if(D<0){w.length=0;break}w.push([$,D]);for(const E of hn(D,Ge($)))h.add(E)}if(w.length===u.length){for(const[$,D]of w)$.slot=D;e.slot=f*Le}else{const $=Do(Gn(nt(c).filter(D=>D.tile!==e)),"full");$>=0?e.slot=$:(e.options.size=a,ke("No page is free for a full-page tile. Free a page first."))}}else if(Lo(e)&&!l){const f=Gn(nt(c).filter(w=>w.tile!==e)),u=_a(e.slot),h=os(f,u,"wide")?u:$a(f,"wide",u);h>=0&&(e.slot=h)}Zs(c),c.pages=jt(nt(c),c.pages)}}function Vc(e,t){const n=`screen.page_${t}`;return!d.layout||n===e.entity||!Xs(n)?!1:d.layout.tiles.some(s=>s.entity===n)?(ke(`This screen already has a tile that goes to page ${t}.`),!1):(e.entity=n,d.selectedTile&&(d.selectedTile=n),Xe(),!0)}function No(e){d.selectedTile!==e&&(d.iconPickerOpen=!1,d.actionPickerOpen=!1,d.actionSearch=""),d.selectedTile=e,d.inspector={kind:"tile"},Qs([e])}function Ot(e){d.inspector?.kind==="bar"&&d.inspector.index===e||(d.iconPickerOpen=!1),d.selectedTile=null,d.inspector={kind:"bar",index:e}}function el(){d.selectedTile=null,d.inspector={kind:"bar-add"}}function Be(){d.inspector=null,d.selectedTile=null}async function Ro(){if(!(d.busy||!d.layout||!d.selected)){d.busy=!0;try{const{settings:e,...t}=d.layout;await st(`screens/${encodeURIComponent(d.selected)}`,"PUT",t),d.dirty=!1,d.saved=Date.now(),ke("Saved. Your screen is being updated."),await bn()}catch(e){ke(e.message)}finally{d.busy=!1}}}const mn=e=>!!(e&&e.alert_action&&Qt(e.firmware,d.inventory.alerts?.min_firmware||"0.2.31"));async function Ma(e){try{await st(`screens/${encodeURIComponent(e.id)}/identify`,"POST"),ke(`${e.name} blinks and shows a card for a few seconds.`)}catch(t){ke(t.message)}}async function Wc(e,t){return await st("alerts/test","POST",{screen:e,data:t})}const Kc=["title","tiles","header","pages"];function La(e,t){const n=d.layout;if(!n)return;const s=(Array.isArray(e.tiles)?e.tiles:[]).filter(r=>!!(r&&typeof r=="object"&&typeof r.entity=="string"&&r.entity.includes("."))).map(r=>({entity:r.entity,name:typeof r.name=="string"?r.name:"",slot:Number.isInteger(r.slot)?r.slot:-1,...r.options&&typeof r.options=="object"?{options:{...r.options}}:{}})),o=new Set,l=s.filter(r=>!o.has(r.entity)&&o.add(r.entity)),a=l.slice(0,vn.value);n.tiles=a,e.header&&Array.isArray(e.header.items)?n.header={items:e.header.items.map(r=>({...r}))}:delete n.header,n.pages=Number.isInteger(e.pages)?e.pages:1,Zs(n),n.pages=jt(nt(n),n.pages),Be(),Xe(),Qs(a.map(r=>r.entity)),eo(),no(0),ke(a.lengthn.id===e);!t||!d.layout||La(JSON.parse(JSON.stringify(t.layout)),`Layout of ${t.name} copied`)}function Yc(){const e=d.layout;if(!e)return"";const t={esp_screens_layout:1};for(const n of Kc)e[n]!==void 0&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Da(){const e=Yc();if(!e)return;const t=`${(Tt.value?.name||"screen").toLowerCase().replace(/[^a-z0-9]+/g,"-")}.layout.json`,n=URL.createObjectURL(new Blob([e],{type:"application/json"})),s=document.createElement("a");s.href=n,s.download=t,s.click(),setTimeout(()=>URL.revokeObjectURL(n),5e3),ct(e,void 0,"Layout JSON")}function zc(e){let t;try{t=JSON.parse(e)}catch{ke("That isn't JSON. Export a layout first, or paste one from another ESP Screens.");return}if(!t||typeof t!="object"||!Array.isArray(t.tiles)){ke("No tiles in this file. Expected a layout exported from ESP Screens.");return}La(t,"Layout imported")}function tl(e){const t=d.inventory.updates?.target,n=d.inventory.updates?.changelog;if(!n||!t)return[];const s=e.firmware,o=[];for(const l of n)if(!(Qt(l.firmware,t)&&l.firmware!==t)&&!(s&&Qt(s,l.firmware)))for(const a of l.lines)o.includes(a)||o.push(a);return o}let wo=!1;async function Jc(){if(!wo){wo=!0;try{const e=await ht("firmware");d.firmwareJob={job:e.job,logs:e.logs||[]}}catch{}finally{wo=!1}}}const Ho=()=>d.inventory.screens.some(e=>e.update?.state==="running")||d.updating.length>0;function $n(e){const t=e.update||{};if(!(t.state==="running"||d.updating.includes(e.id)))return null;const n=d.firmwareJob?.job?.stage;return t.phase==="verify"?{percent:78,text:Vn.verify}:t.phase==="settle"?{percent:92,text:Vn.settle}:t.phase==="install"||!t.phase?n==="upload"?{percent:66,text:"Writing the firmware over Wi-Fi…"}:n?{percent:40,text:"Building the firmware…"}:{percent:12,text:Vn.install}:{percent:12,text:Vn[t.phase]||"Starting update…"}}const Ht=()=>d.layout?.header?.items??(d.layout?.settings?.show_clock??!0?[{type:"clock"}]:[]),qt=()=>d.inventory.header?.max_items||6;function is(e){d.layout&&(d.layout.header={items:e},Xe(),no())}let nl=0;function no(e=150){clearTimeout(nl),nl=window.setTimeout(async()=>{const t=Ht();if(t.some(n=>n.type==="entity"))try{const n=await st("header-preview","POST",{header:{items:t}});t.forEach((s,o)=>d.topbarPreviews[Ke(s)]=n.items[o])}catch{}},e)}function jn(e){return e.type==="entity"?ds(e.entity):d.inventory.header?.builtin.find(t=>t.type===e.type)?.label||e.type}function Vt(e){const t=new Date(d.now);if(e.type==="clock")return{text:li(Pn().clock_24h!==!1,t),shown:!0};if(e.type==="date")return{text:ai(t),shown:!0};if(e.type==="analog")return{analog:!0,shown:!0};const n=d.topbarPreviews[Ke(e)];return n?{icon:n.i||null,text:n.k==="ago"?gc(n.e,Math.floor(d.now/1e3)):n.t,color:n.c?`#${n.c}`:null,shown:n.shown}:{icon:e.icon==="none"?null:fs(e.icon)?.cp||en(e.entity),text:"…",shown:!0,loading:!0}}function _o(e,t){const n=[...Ht()];return t<0||t>=n.length||e===t?!1:(n.splice(t,0,...n.splice(e,1)),is(n),!0)}function sl(e){const t=[...Ht()],[n]=t.splice(e,1);n&&(d.inspector?.kind==="bar"&&Be(),is(t),ke(`${jn(n)} removed from the top bar`,{label:"Undo",run:()=>{const s=[...Ht()];s.splice(Math.min(e,s.length),0,n),is(s)}}))}function ko(e){const t=Ht();if(t.length>=qt())return ke(`The top bar has room for ${qt()} items.`);if(t.some(n=>Ke(n)===Ke(e)))return ke("This is already in the top bar.");d.topbarAdded={key:Ke(e),time:Date.now()},is([...t,e]),Ot(t.length)}const Gc=[{title:"Brightness",icon:"F0599",rows:[{key:"brightness",label:"Brightness",kind:"number",min:5,max:100,step:5,unit:"%"},{key:"dark_mode",label:"Dark mode",kind:"toggle"},{key:"standby_enabled",label:"Auto standby",kind:"toggle"},{key:"standby_seconds",label:"Standby after",kind:"duration",min:60,max:86400,needs:"standby_enabled"},{key:"standby_brightness",label:"Standby brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"standby_enabled",cap:"brightness"}]},{title:"Night",icon:"F0594",rows:[{key:"night_enabled",label:"Night mode",kind:"toggle"},{key:"night_start",label:"Starts",kind:"moment",needs:"night_enabled"},{key:"night_end",label:"Ends",kind:"moment",needs:"night_enabled"},{key:"night_brightness",label:"Night brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"night_enabled",cap:"brightness"}]},{title:"Screen",icon:"F0379",rows:[{key:"clock_24h",label:"Clock",kind:"choice",options:[[!1,"12 hour"],[!0,"24 hour"]]},{key:"auto_home",label:"Back to page 1",kind:"toggle"},{key:"auto_home_seconds",label:"After",kind:"duration",min:30,max:3600,needs:"auto_home"},{key:"home_on_standby",label:"Also on standby",kind:"toggle"},{key:"swipe_pages",label:"Swipe between pages",kind:"toggle"},{key:"rotation",label:"Rotation",kind:"choice",options:[[0,"0°"],[90,"90°"],[180,"180°"],[270,"270°"]]}]}],Fa=4e3;let Yt={},Cn=null,Ms=0,xs=null;const di=()=>Tt.value?.settings;function Pn(){const e=di(),t={...e?.values||{}};for(const[n,s]of Object.entries(d.settingEdits))t[n]=s.value;return t}const Xc=e=>e<300?30:e<900?60:e<3600?300:e<7200?900:1800;function ol(e,t,n,s,o){if(e.kind==="moment"){let r=s&&t%60?Math.floor(t/60)*60+(n>0?60:0):t+n*(s?60:15);return r%=1440,r<0?r+1440:r}const l=e.kind==="duration"?Xc(n<0?t-1:t):e.step,a=e.cap?Math.min(e.max,o[e.cap]):e.max;return Math.min(a,Math.max(e.min,t+n*l))}function Zc(e){if(e<60)return`${e} sec`;if(e<3600)return`${Math.floor(e/60)} min`;const t=Math.floor(e/3600),n=Math.floor(e%3600/60);return n?`${t} h ${String(n).padStart(2,"0")}`:`${t} h`}function Qc(e,t){const n=Math.floor(e/60),s=String(e%60).padStart(2,"0");return t?`${String(n).padStart(2,"0")}:${s}`:`${n%12||12}:${s} ${n<12?"AM":"PM"}`}function ed(e,t){const n=t[e.key];return n==null?"—":e.kind==="number"?`${n}${e.unit||""}`:e.kind==="duration"?Zc(n):e.kind==="moment"?Qc(n,t.clock_24h!==!1):""}function Bn(e,t,n){if(Cn&&Cn!==d.selected&&Object.keys(Yt).length){ls(),ke("Still saving the other screen's settings. Try again in a moment.");return}Cn=d.selected;const s=Pn();if(d.settingEdits[e]={value:t,at:Date.now()},Yt[e]=t,e==="brightness")for(const o of["standby_brightness","night_brightness"])s[o]>t&&(d.settingEdits[o]={value:t,at:Date.now()});d.settingPending=!0,clearTimeout(Ms),Ms=window.setTimeout(()=>ls(),n)}async function ls(e=!1){if(clearTimeout(Ms),xs||!Object.keys(Yt).length||!Cn)return;const t=Cn,n=Yt;Yt={};const s=ri(`screens/${encodeURIComponent(t)}/settings`,{method:"PUT",body:JSON.stringify({settings:n}),keepalive:e});xs=s;try{const o=await(await s).json(),l=d.inventory.screens.find(a=>a.id===t);l&&(l.settings=o)}catch(o){if(ke(o.message),t===d.selected)for(const l of Object.keys(n))delete d.settingEdits[l];if(t===d.selected&&n.brightness!==void 0)for(const l of["standby_brightness","night_brightness"])delete d.settingEdits[l]}finally{xs=null,Object.keys(Yt).length?Ms=window.setTimeout(()=>ls(),150):Cn=null,d.settingPending=!!Object.keys(Yt).length,t===d.selected&&Ls(),setTimeout(()=>{t===d.selected&&Ls()},Fa+100)}}function Ls(){const e=di();for(const[t,n]of Object.entries(d.settingEdits))Yt[t]!==void 0||xs||(e&&e.values[t]===n.value||Date.now()-n.at>Fa)&&delete d.settingEdits[t]}const Vn={install:"Building and installing…",verify:"Waiting for the screen to come back…",settle:"Checking that it stays stable…"};async function Uo(e,t){d.updating.push(e.id);try{await st(`screens/${encodeURIComponent(e.id)}/update`,"POST",t?{host:t}:{}),await bn()}catch(n){d.updating=d.updating.filter(s=>s!==e.id),ke(n.message)}}async function il(){try{await st("updates/run","POST"),await bn()}catch(e){ke(e.message)}}async function td(e){try{await st("updates","PUT",{auto:e}),d.inventory.updates&&(d.inventory.updates.auto=e),ke(e?"Screens will now update automatically at night.":"Automatic updates are off.")}catch(t){ke(t.message)}}async function nd(){try{d.inventory.claude_skill=await st("claude-skill","POST"),ke(d.inventory.claude_skill?.restart?"Skill installed. Restart Claude Code once so it finds the new skills folder.":"Skill installed. Claude Code picks it up right away.")}catch(e){ke(e.message)}}async function bn(e=!0){try{const t=await ht(e?"inventory":"inventory?light=1");d.inventory=e?t:{...d.inventory,...t},t.csrf&&Sc(t.csrf),d.connected=!!d.inventory.connected,d.reachable=!0;for(const n of d.inventory.screens)n.update?.state==="running"&&(d.updating=d.updating.filter(s=>s!==n.id));d.selected&&Ls()}catch{d.reachable=!1}}function sd(e){d.inventory={...d.inventory,...e},d.connected=!!d.inventory.connected;for(const t of d.inventory.screens)t.update?.state==="running"&&(d.updating=d.updating.filter(n=>n!==t.id));d.selected&&Ls()}let ll=0,jo=Date.now(),Ds=!1,Nn=null;function od(){Nn||typeof EventSource>"u"||(Nn=new EventSource("api/events"),Nn.onopen=()=>{Ds=!0,as()},Nn.onmessage=e=>{document.hidden||sd(JSON.parse(e.data))},Nn.onerror=()=>{Ds=!1,as()})}function as(){clearTimeout(ll);const e=Ds?6e4:d.inventory.updates?.busy?3e3:1e4;ll=window.setTimeout(async()=>{if(!document.hidden){const t=Date.now()-jo>=3e5;t&&(jo=Date.now()),(t||!Ds)&&await bn(t)}as()},e)}let al=!1;function id(){al||(al=!0,bn(),od(),as(),xc(()=>d.fontsVersion++),setInterval(()=>{!d.layout||document.hidden||d.drag.active||(d.now=Date.now(),no(0))},3e4),setInterval(()=>{!document.hidden&&d.layout&&d.tab==="layout"&<.value===""&&eo()},8e3),setInterval(()=>{!document.hidden&&Ho()?Jc():d.firmwareJob&&!Ho()&&(d.firmwareJob=null)},3e3),document.addEventListener("visibilitychange",async()=>{document.hidden||(jo=Date.now(),d.now=Date.now(),await bn(),as())}),window.addEventListener("pagehide",()=>ls(!0)),window.addEventListener("beforeunload",e=>{d.dirty&&(e.preventDefault(),e.returnValue="")}))}const ld={class:"side"},ad={id:"screens"},rd=["aria-current","onClick"],ud={class:"txt"},cd={key:0,class:"pill"},dd={key:1,class:"spin small"},fd=["onSubmit"],pd=["disabled","title","onClick"],hd={key:2,class:"whatsnew"},vd=["aria-valuenow"],md={class:"progress-text"},yd=["title"],gd=["title"],bd={id:"pending"},wd={class:"pending-actions"},_d=["onClick"],kd=["aria-current"],$d={class:"more"},xd=["aria-current"],Sd={class:"mdi"},Cd=["aria-current"],Td={class:"mdi"},Ad=["aria-current"],Ed={class:"mdi"},Pd=Te({__name:"Sidebar",setup(e){const t=de(null),n=de("");function s(c){const f=c.update||{};return f.state==="running"||d.updating.includes(c.id)?{kind:"running",text:Vn[f.phase||""]||"Starting update…"}:f.state==="queued"?{kind:"queued",text:"Queued for the update"}:f.available&&c.online?{kind:"available",text:`Update ${f.target}`}:f.result&&Date.now()/1e3-f.result.time<86400?{kind:f.result.state==="failed"?"failed":"done",text:f.result.message}:null}function o(c){const f=c.update||{};f.host&&f.profile?Uo(c):f.profile&&(t.value=c.id,n.value="")}function l(c){const f=n.value.trim();f&&(t.value=null,Uo(c,f))}const a=()=>{const c=d.firmwareJob?.logs||[];return c.length?c[c.length-1]:""},r=c=>c.installed?"Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure.":c.downloaded?"Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure.":`Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${c.file}.`;return(c,f)=>(p(),v("aside",ld,[f[20]||(f[20]=i("div",{class:"brand"},[i("span",{class:"mark"},"▦"),i("span",null,"ESP Screens")],-1)),i("span",{id:"connection",class:le(["conn",{online:y(d).connected}]),role:"status"},m(y(d).reachable?y(d).connected?"Home Assistant connected":"Reconnecting to Home Assistant…":"Management page unreachable · retrying…"),3),i("button",{type:"button",class:"search-btn",id:"open-palette",onClick:f[0]||(f[0]=u=>y(d).palette=!0)},[...f[10]||(f[10]=[X("⌕ Search",-1),i("kbd",null,"⌘K",-1)])]),f[21]||(f[21]=i("div",{class:"label"},"Screens",-1)),i("div",ad,[(p(!0),v(B,null,ie(y(d).inventory.screens,u=>(p(),v("div",{key:u.id,class:le(["screen-item",{selected:u.id===y(d).selected&&y(lt)===""}])},[i("button",{type:"button",class:"nav-item","aria-current":u.id===y(d).selected&&y(lt)===""?"true":"false",onClick:h=>y(Pa)(u.id)},[i("span",{class:le(["dot",{off:!u.online}])},null,2),i("span",ud,[i("span",null,m(u.name),1),i("small",null,m(u.online?"Online":"Offline")+m(u.area?" · "+u.area:"")+" · "+m(u.firmware||"unknown"),1)]),s(u)?.kind==="available"?(p(),v("span",cd,"Update")):s(u)?.kind==="running"?(p(),v("span",dd)):j("",!0)],8,rd),s(u)?(p(),v("div",{key:0,class:le(["screen-update",s(u).kind])},[s(u).kind==="available"?(p(),v(B,{key:0},[t.value===u.id?(p(),v(B,{key:0},[f[12]||(f[12]=i("small",null,"The IP address, once; new firmware reports it itself.",-1)),i("form",{class:"screen-host",onSubmit:ut(h=>l(u),["prevent"])},[ye(i("input",{"onUpdate:modelValue":f[1]||(f[1]=h=>n.value=h),placeholder:"IP address, e.g. 192.168.1.50",required:"",pattern:"[A-Za-z0-9][A-Za-z0-9.\\-]*","aria-label":"IP address",autofocus:""},null,512),[[De,n.value]]),f[11]||(f[11]=i("button",{type:"submit",class:"btn mini primary"},"Start",-1)),i("button",{type:"button",class:"icon-btn","aria-label":"Cancel",onClick:f[2]||(f[2]=h=>t.value=null)},"✕")],40,fd)],64)):(p(),v(B,{key:1},[i("small",null,m(s(u).text),1),i("button",{type:"button",class:"btn mini primary",disabled:!u.update?.profile,title:u.update?.profile?"":"No ESPHome profile found with this device name.",onClick:h=>o(u)},"Update",8,pd)],64)),y(tl)(u).length?(p(),v("details",hd,[f[13]||(f[13]=i("summary",null,"What's new",-1)),i("ul",null,[(p(!0),v(B,null,ie(y(tl)(u).slice(0,8),h=>(p(),v("li",{key:h},m(h),1))),128))])])):j("",!0)],64)):s(u).kind==="running"&&y($n)(u)?(p(),v(B,{key:1},[i("div",{class:"progress",role:"progressbar","aria-valuenow":y($n)(u).percent,"aria-valuemin":"0","aria-valuemax":"100"},[i("i",{style:Ae({width:y($n)(u).percent+"%"})},null,4)],8,vd),i("div",md,[i("span",null,m(y($n)(u).percent)+" %",1),i("span",{title:a()},m(y($n)(u).text),9,yd)]),a()?(p(),v("small",{key:0,title:a(),style:{"white-space":"nowrap",overflow:"hidden","text-overflow":"ellipsis"}},m(a()),9,gd)):j("",!0),i("button",{type:"button",class:"btn link mini",style:{"justify-self":"start"},onClick:f[3]||(f[3]=h=>y(Ee)("#firmware"))},"Full log")],64)):(p(),v("small",{key:2,class:le({failed:s(u).kind==="failed"})},m(s(u).text),3))],2)):j("",!0)],2))),128))]),i("div",bd,[(p(!0),v(B,null,ie(y(d).inventory.pending||[],u=>(p(),v("div",{key:u.file,class:"pending"},[i("strong",null,m(u.friendly),1),i("small",null,m(r(u)),1),i("div",wd,[i("button",{type:"button",class:"btn mini quiet",onClick:f[4]||(f[4]=(...h)=>y(Is)&&y(Is)(...h))},"Open Devices & services"),u.api_key?(p(),v("button",{key:0,type:"button",class:"btn mini quiet",onClick:h=>y(ct)(u.api_key)},"Copy API key",8,_d)):j("",!0)])]))),128))]),i("button",{id:"new-screen",type:"button",class:"nav-item ghost","aria-current":y(lt)==="#new-screen"?"true":"false",onClick:f[5]||(f[5]=u=>y(Ee)("#new-screen"))},[...f[14]||(f[14]=[i("span",{class:"plus"},"+",-1),i("span",{class:"txt"},"New screen",-1)])],8,kd),f[22]||(f[22]=i("div",{class:"spacer"},null,-1)),i("div",$d,[f[19]||(f[19]=i("div",{class:"label"},"More",-1)),i("button",{id:"open-firmware",type:"button",class:"nav-item","aria-current":y(lt)==="#firmware"?"true":"false",onClick:f[6]||(f[6]=u=>y(Ee)("#firmware"))},[i("span",Sd,m(y(ge)("F0241")),1),f[15]||(f[15]=i("span",{class:"txt"},"Firmware & USB",-1))],8,xd),i("button",{id:"open-alerts",type:"button",class:"nav-item","aria-current":y(lt)==="#alerts"?"true":"false",onClick:f[7]||(f[7]=u=>y(Ee)("#alerts"))},[i("span",Td,m(y(ge)("F0594")),1),f[16]||(f[16]=i("span",{class:"txt"},"Alerts",-1))],8,Cd),i("button",{id:"open-settings",type:"button",class:"nav-item","aria-current":y(lt)==="#settings"?"true":"false",onClick:f[8]||(f[8]=u=>y(Ee)("#settings"))},[i("span",Ed,m(y(ge)("F0493")),1),f[17]||(f[17]=i("span",{class:"txt"},"Settings",-1))],8,Ad),i("button",{id:"refresh",type:"button",class:"nav-item ghost",onClick:f[9]||(f[9]=u=>y(bn)())},[...f[18]||(f[18]=[i("span",{class:"plus"},"↻",-1),i("span",{class:"txt"},"Refresh",-1)])])])]))}}),Od={key:0,id:"toast",class:"toast",role:"status"},Id=Te({__name:"Toast",setup(e){return(t,n)=>y(d).toast?(p(),v("div",Od,[i("span",null,m(y(d).toast.message),1),y(d).toast.action?(p(),v("button",{key:0,type:"button",onClick:n[0]||(n[0]=s=>{y(d).toast.action.run(),y(Fc)()})},m(y(d).toast.action.label),1)):j("",!0)])):j("",!0)}}),Md={class:"palette-list"},Ld={class:"palette-group"},Dd=["onMouseenter","onClick"],Fd={key:0,class:"mdi"},Nd={key:1,class:"glyph"},Rd={class:"tx"},Hd={key:0},Ud={key:2,class:"hint-key"},jd={key:0,class:"palette-empty"},Bd=Te({__name:"CommandPalette",setup(e){const t=de(""),n=de(0),s=de(null),o=k(()=>{const f=t.value.trim().toLocaleLowerCase(),u=[];for(const $ of d.inventory.screens)u.push({group:"Screens",label:$.name,detail:`${$.online?"Online":"Offline"} · ${$.firmware||"unknown"}`,glyphText:"▦",run:()=>Pa($.id)});const h=Tt.value,w=[{group:"Actions",label:"New screen",detail:"Connect and install",glyphText:"+",run:()=>Ee("#new-screen")},{group:"Actions",label:"Firmware & USB",icon:"F0241",run:()=>Ee("#firmware")},{group:"Actions",label:"Alerts",detail:"Cheatsheet and a test alert",icon:"F0594",run:()=>Ee("#alerts")},{group:"Actions",label:"Settings",detail:"Updates, Claude",icon:"F0493",run:()=>Ee("#settings")}];if(h&&d.layout&&w.unshift({group:"Actions",label:"Save & send",detail:d.dirty?"Unsaved changes":"Nothing to save",key:"⌘S",run:()=>Ro()},{group:"Actions",label:"Layout",detail:h.name,run:()=>{Ee(""),d.tab="layout"}},{group:"Actions",label:"Screen settings",detail:h.name,run:()=>{Ee(""),d.tab="settings"}},{group:"Actions",label:"Identify this screen",detail:mn(h)?"Blinks the backlight and shows a card":"Needs a newer firmware",run:()=>{mn(h)&&Ma(h)}},{group:"Actions",label:"Export layout",detail:"Download and copy as JSON",run:Da}),u.push(...w),h&&d.layout&&f){const $=new Set(d.layout.tiles.map(E=>E.entity)),D=d.layout.tiles.length>=vn.value;for(const E of d.inventory.entities)if(!(E.tile===!1||$.has(E.id))&&`${E.name} ${E.id} ${E.area||""} ${E.device||""}`.toLocaleLowerCase().includes(f)&&(u.push({group:"Add to this screen",label:E.name,detail:[wt(E.id)[0],E.area].filter(Boolean).join(" · "),icon:d.inventory.icons?en(E.id):void 0,run:()=>{D||Oa(E.id)}}),u.length>60))break}return f?u.filter($=>`${$.label} ${$.detail||""}`.toLocaleLowerCase().includes(f)):u}),l=k(()=>{const f=[];return o.value.forEach((u,h)=>{(f.find($=>$.group===u.group)||(f.push({group:u.group,items:[]}),f[f.length-1])).items.push({item:u,index:h})}),f});function a(){d.palette=!1}function r(f){a(),f.run()}function c(f){if(f.key==="Escape")f.preventDefault(),a();else if(f.key==="ArrowDown")f.preventDefault(),n.value=Math.min(o.value.length-1,n.value+1);else if(f.key==="ArrowUp")f.preventDefault(),n.value=Math.max(0,n.value-1);else if(f.key==="Enter"){f.preventDefault();const u=o.value[n.value];u&&r(u)}}return dn(t,()=>n.value=0),dn(()=>d.palette,async f=>{f&&(t.value="",n.value=0,await Ks(),s.value?.focus())}),(f,u)=>y(d).palette?(p(),v("div",{key:0,class:"palette-backdrop",onClick:a},[i("div",{class:"palette",role:"dialog","aria-label":"Search",onClick:u[1]||(u[1]=ut(()=>{},["stop"])),onKeydown:c},[ye(i("input",{ref_key:"input",ref:s,"onUpdate:modelValue":u[0]||(u[0]=h=>t.value=h),id:"palette-input",placeholder:"Search screens, entities, actions…","aria-label":"Search",autocomplete:"off"},null,512),[[De,t.value]]),i("div",Md,[(p(!0),v(B,null,ie(l.value,h=>(p(),v(B,{key:h.group},[i("div",Ld,m(h.group),1),(p(!0),v(B,null,ie(h.items,({item:w,index:$})=>(p(),v("button",{key:$,type:"button",class:le(["palette-item",{active:$===n.value}]),onMouseenter:D=>n.value=$,onClick:D=>r(w)},[w.icon?(p(),v("span",Fd,m(y(ge)(w.icon)),1)):(p(),v("span",Nd,m(w.glyphText||"›"),1)),i("span",Rd,[i("span",null,m(w.label),1),w.detail?(p(),v("small",Hd,m(w.detail),1)):j("",!0)]),w.key?(p(),v("kbd",Ud,m(w.key),1)):j("",!0)],42,Dd))),128))],64))),128)),o.value.length?j("",!0):(p(),v("p",jd,"Nothing matches. Type part of a screen, entity or action."))])],32)])):j("",!0)}}),se={source:null,element:null,ghost:null,timer:0,start:null,offset:{x:0,y:0},pointerId:null,suppressUntil:0,last:null,scroller:0,target:null},Na={mounted(e,t){e.__dragSource=t.value,e.addEventListener("pointerdown",s=>{const o=e.__dragSource;if(!(s.button!==0||e.disabled||s.target.closest(".remove"))){s.pointerType!=="touch"&&s.preventDefault(),Object.assign(se,{source:o,element:e,start:{x:s.clientX,y:s.clientY},pointerId:s.pointerId});try{e.setPointerCapture(s.pointerId)}catch{}clearTimeout(se.timer),s.pointerType==="touch"&&(se.timer=window.setTimeout(()=>rl(s),260))}}),e.addEventListener("pointermove",s=>{if(d.drag.active||!se.start||se.element!==e)return;const o=Math.hypot(s.clientX-se.start.x,s.clientY-se.start.y);if(s.pointerType==="touch"){o>10&&(clearTimeout(se.timer),se.start=null);return}o>=6&&rl(s)});const n=()=>{se.element===e&&!d.drag.active&&(clearTimeout(se.timer),se.start=null)};e.addEventListener("pointerup",n),e.addEventListener("pointercancel",n)},updated(e,t){e.__dragSource=t.value}};function rl(e){if(d.drag.active||!se.start||!se.source||!se.element)return;d.drag.active=!0,d.drag.moving=se.source.kind==="tile"?se.source.tile:Sa(se.source.id),d.drag.preview=null,se.target=null,getSelection()?.removeAllRanges();const t=se.element.getBoundingClientRect(),n=se.element.cloneNode(!0);n.classList.add("drag-ghost"),n.style.width=`${t.width}px`,n.style.height=`${t.height}px`,se.offset={x:e.clientX-t.left,y:e.clientY-t.top},document.body.append(n),se.ghost=n,document.addEventListener("pointermove",Bo),document.addEventListener("pointerup",Fs),document.addEventListener("pointercancel",Fs);try{se.element.releasePointerCapture(se.pointerId)}catch{}try{document.documentElement.setPointerCapture(se.pointerId)}catch{}document.addEventListener("touchmove",Ra,{passive:!1}),se.scroller=window.setInterval(()=>{if(!se.last)return;const s=document.querySelector(".canvas");if(!s)return;const o=s.getBoundingClientRect(),l=se.last.xo.right-60?12:0,a=se.last.yo.bottom-60?12:0;(l||a)&&(s.scrollBy(l,a),Vo(Ha(se.last.x,se.last.y)))},16),Vo(-1),Bo(e)}function Ra(e){d.drag.active&&e.preventDefault()}function Fs(e){e.pointerId===se.pointerId&&Vd(e.type==="pointerup")}function Bo(e){!se.ghost||e.pointerId!==se.pointerId||(se.last={x:e.clientX,y:e.clientY},se.ghost.style.transform=`translate(${e.clientX-se.offset.x}px, ${e.clientY-se.offset.y}px)`,Vo(Ha(e.clientX,e.clientY)))}function Ha(e,t){let n=null,s=1/0;for(const l of document.querySelectorAll(".pages [data-slot]")){const a=l.getBoundingClientRect(),r=Math.hypot(Math.max(a.left-e,0,e-a.right),Math.max(a.top-t,0,t-a.bottom));r16)return-1;let o=Number(n.cell.dataset.slot);return n.cell.classList.contains("wide")&&e>(n.r.left+n.r.right)/2&&(o+=1),o}function Vo(e){se.target===e||!d.layout||!d.drag.moving||(se.target=e,d.drag.preview=e>=0?xa(d.layout.tiles,d.drag.moving,e):null)}function Vd(e){const t=d.drag.preview,n=d.drag.moving;document.removeEventListener("pointermove",Bo),document.removeEventListener("pointerup",Fs),document.removeEventListener("pointercancel",Fs),document.removeEventListener("touchmove",Ra);try{document.documentElement.releasePointerCapture(se.pointerId)}catch{}if(se.ghost?.remove(),Object.assign(se,{ghost:null,suppressUntil:Date.now()+400,last:null,start:null,target:null,element:null,source:null}),clearInterval(se.scroller),d.drag.active=!1,d.drag.preview=null,d.drag.moving=null,e&&t&&n&&d.layout){const s=d.layout.tiles.map(o=>`${o.entity}@${o.slot}`).join();for(const{tile:o,slot:l}of t)o.slot=l,d.layout.tiles.includes(o)||d.layout.tiles.push(o);d.layout.tiles.sort((o,l)=>o.slot-l.slot),d.layout.tiles.map(o=>`${o.entity}@${o.slot}`).join()!==s&&Xe(),s.includes(`${n.entity}@`)||to(n,n.slot)}}window.addEventListener("click",e=>{Date.now()t.tile.name||ds(t.tile.entity)),s=k(()=>ba(t.tile)),o=k(()=>Lo(t.tile)&&!s.value),l=k(()=>Xs(t.tile.entity)),a=k(()=>d.inventory.backgrounds?.[t.tile.options?.background||""]?.color),r=k(()=>t.tile.options?.background==="none"),c=k(()=>t.tile.options?.display||"standard"),f=k(()=>c.value!=="standard"?Ta[c.value]||c.value:""),u=k(()=>Ca(t.tile,d.inventory)),h=k(()=>t.tile.entity.split(".")[0]),w=k(()=>d.inventory.icons?.controls||{}),$=Z=>w.value[Z]?ge(w.value[Z]):"",D=k(()=>d.selectedTile===t.tile.entity&&d.inspector?.kind==="tile"),E=k(()=>!t.placeholder&&d.layout?.tiles.includes(t.tile)),x=k(()=>`${n.value}, slot ${t.slot%Le+1} on page ${En(t.slot)+1}. Enter: configure, arrow keys: move`),S=k(()=>new Date(d.now)),L=k(()=>(S.value.getHours()%12+S.value.getMinutes()/60)*30),J=k(()=>S.value.getMinutes()*6),C=k(()=>h.value==="screen"?null:Hc(t.tile.entity)),V=k(()=>!C.value||["unavailable","unknown",""].includes(C.value.state)),I=k(()=>!!C.value&&!V.value&&C.value.state!=="off"&&C.value.state!=="closed"&&C.value.state!=="standby"&&C.value.state!=="idle"&&C.value.state!=="docked"),A=k(()=>["light","switch","input_boolean","fan"].includes(h.value)&&C.value?.state==="on"),b=k(()=>C.value?.a?.unit_of_measurement),T=Z=>Z.charAt(0).toUpperCase()+Z.slice(1).replace(/_/g," "),ne={"clear-night":"Clear, night",cloudy:"Cloudy",exceptional:"Exceptional",fog:"Fog",hail:"Hail",lightning:"Lightning","lightning-rainy":"Lightning, rainy",partlycloudy:"Partly cloudy",pouring:"Pouring",rainy:"Rainy",snowy:"Snowy","snowy-rainy":"Snowy, rainy",sunny:"Sunny",windy:"Windy","windy-variant":"Windy"},Se=["scene","script","button","input_button"],Oe=k(()=>{const Z=C.value;if(!Z||Se.includes(h.value))return f.value;if(V.value)return Z.state==="unknown"?"Unknown":"Unavailable";const ue=Z.a||{};return h.value==="climate"?`${ue.current_temperature!==void 0?`${ue.current_temperature}° · `:""}${Z.word||T(Z.state)}`:h.value==="weather"?`${Z.word||ne[Z.state]||T(Z.state)}${ue.temperature!==void 0?` · ${ue.temperature}°`:""}`:h.value==="cover"&&ue.current_position!==void 0&&ue.current_position>0&&ue.current_position<100?`${Z.word||T(Z.state)} · ${ue.current_position} %`:h.value==="media_player"&&ue.media_title?`${Z.word||T(Z.state)} · ${ue.media_title}`:h.value==="sensor"?`${Z.state}${b.value?` ${b.value}`:""}`:(h.value==="timer",Z.word||T(Z.state))}),G=k(()=>C.value&&!V.value?C.value.state:"—"),M=k(()=>{const Z=C.value;if(!Z||V.value)return 0;const ue=Z.a||{};if(h.value==="light")return Z.state==="on"?ue.brightness!==void 0?Math.round(ue.brightness/255*100):100:0;if(h.value==="fan")return Z.state==="on"?ue.percentage??100:0;if(h.value==="cover")return ue.current_position??(Z.state==="open"?100:0);if(h.value==="media_player")return Math.round((ue.volume_level??0)*100);if(h.value==="number"||h.value==="input_number"){const Me=Number(Z.state),Fe=Number(ue.min??0),nn=Number(ue.max??100);return Number.isFinite(Me)&&nn>Fe?Math.round((Me-Fe)/(nn-Fe)*100):0}return 0}),P=k(()=>({background:`linear-gradient(to right, ${M.value?"#ffbf38":"#c9ccd1"} ${M.value}%, ${M.value?"#fff1d3":"#e6e8ec"} ${M.value}%)`})),z=k(()=>({background:`linear-gradient(to right, #2196f3 ${M.value}%, #d3e8fb ${M.value}%)`})),oe=k(()=>{const Z=C.value?.a?.temperature;return Z!=null?`${Z}°`:"—"});async function fe(Z){if(Z.key==="Enter"||Z.key===" "){Z.preventDefault(),No(t.tile.entity);return}const ue={ArrowLeft:-1,ArrowRight:1,ArrowUp:-2,ArrowDown:2}[Z.key];ue&&(Z.preventDefault(),to(t.tile,t.tile.slot+(s.value?Math.sign(ue)*Le:o.value?Math.sign(ue)*2:ue))&&(await Ks(),document.querySelector(`.pages [data-slot="${t.tile.slot}"]`)?.focus()))}return(Z,ue)=>ye((p(),v("div",{class:le(["tile",{wide:o.value,full:s.value,bare:r.value,placeholder:e.placeholder||!E.value,chosen:D.value}]),"data-slot":e.slot,style:Ae(a.value&&!r.value?{backgroundColor:a.value}:void 0),tabindex:E.value?0:-1,role:E.value?"button":void 0,"aria-label":E.value?x.value:void 0,onClick:ue[1]||(ue[1]=Me=>E.value&&y(No)(e.tile.entity)),onKeydown:ue[2]||(ue[2]=Me=>E.value&&fe(Me))},[c.value==="analog"?(p(),v(B,{key:0},[(p(),v("svg",Kd,[ue[3]||(ue[3]=i("circle",{cx:"30",cy:"30",r:"27",fill:"#fff",stroke:"#c9ccd1"},null,-1)),(p(),v(B,null,ie([0,90,180,270],Me=>i("line",{key:Me,x1:"30",y1:"5",x2:"30",y2:"9",stroke:"#1b1b1b","stroke-width":"1.5",transform:`rotate(${Me} 30 30)`},null,8,qd)),64)),i("line",{x1:"30",y1:"30",x2:"30",y2:"16",stroke:"#1b1b1b","stroke-width":"2.4","stroke-linecap":"round",transform:`rotate(${L.value} 30 30)`},null,8,Yd),i("line",{x1:"30",y1:"30",x2:"30",y2:"11",stroke:"#1b1b1b","stroke-width":"1.6","stroke-linecap":"round",transform:`rotate(${J.value} 30 30)`},null,8,zd),ue[4]||(ue[4]=i("circle",{cx:"30",cy:"30",r:"1.8",fill:"#1b1b1b"},null,-1))])),o.value?(p(),v("span",Jd,[i("span",Gd,[i("span",Xd,m(n.value),1),i("span",Zd,m(f.value),1)])])):j("",!0)],64)):c.value==="digital"&&h.value==="screen"?(p(),v(B,{key:1},[i("span",Qd,m(y(ge)(y(Un)(e.tile))),1),i("span",ef,[i("span",tf,m(String(S.value.getHours()).padStart(2,"0"))+":"+m(String(S.value.getMinutes()).padStart(2,"0")),1),i("span",nf,m(n.value),1)])],64)):s.value?(p(),v(B,{key:2},[i("span",{class:le(["ic mdi",{lit:A.value}])},m(y(ge)(y(Un)(e.tile))),3),i("span",sf,[i("span",of,m(n.value),1),l.value?(p(),v("span",lf,"Page "+m(l.value)+" ›",1)):c.value==="watch"?(p(),v("span",af,[X(m(G.value),1),b.value&&!V.value?(p(),v("small",rf,m(b.value),1)):j("",!0)])):Oe.value?(p(),v("span",{key:2,class:le(["st",{off:V.value}])},m(Oe.value),3)):j("",!0)]),e.tile.options?.inline==="slider"?(p(),v("span",{key:0,class:"mini-slider",style:Ae(P.value)},null,4)):j("",!0),u.value?(p(),v("span",uf,[u.value==="toggle"?(p(),v("span",{key:0,class:le(["tog",{off:!I.value}])},null,2)):u.value==="setpoint"?(p(),v("span",cf,[i("span",df,m($("minus")||"−"),1),i("b",null,m(oe.value),1),i("span",ff,m($("plus")||"+"),1)])):u.value==="volume"?(p(),v(B,{key:2},[i("span",{class:"range",style:Ae(z.value)},null,4),i("span",pf,m($("volume-high")),1)],64)):u.value==="playback"?(p(),v(B,{key:3},[i("span",hf,m($("skip-previous")),1),i("span",vf,m($(I.value?"pause":"play")||$("play")),1),i("span",mf,m($("skip-next")),1)],64)):u.value==="buttons"&&h.value==="cover"?(p(),v(B,{key:4},[i("span",yf,m($("arrow-expand-horizontal")),1),i("span",gf,m($("stop")),1),i("span",bf,m($("arrow-collapse-horizontal")),1)],64)):u.value==="run"?(p(),v("span",wf,m({scene:"Activate",script:"Run"}[h.value]||"Press"),1)):(p(),v("span",{key:6,class:"range",style:Ae({background:`linear-gradient(to right, #2196f3 ${M.value}%, #d3e8fb ${M.value}%)`})},null,4))])):j("",!0)],64)):o.value?(p(),v(B,{key:3},[i("span",_f,[i("span",{class:le(["ic mdi",{lit:A.value}])},m(y(ge)(y(Un)(e.tile))),3),i("span",kf,[i("span",$f,m(n.value),1),l.value?(p(),v("span",xf,"Page "+m(l.value)+" ›",1)):c.value==="watch"?(p(),v("span",Sf,[X(m(G.value),1),b.value&&!V.value?(p(),v("small",Cf,m(b.value),1)):j("",!0)])):Oe.value?(p(),v("span",{key:2,class:le(["st",{off:V.value}])},m(Oe.value),3)):j("",!0)])]),e.tile.options?.inline==="slider"?(p(),v("span",{key:0,class:"mini-slider",style:Ae(P.value)},null,4)):j("",!0),u.value?(p(),v("span",Tf,[u.value==="toggle"?(p(),v("span",{key:0,class:le(["tog",{off:!I.value}])},null,2)):u.value==="setpoint"?(p(),v("span",Af,[i("span",Ef,m($("minus")||"−"),1),i("b",null,m(oe.value),1),i("span",Pf,m($("plus")||"+"),1)])):u.value==="stepper"&&h.value.endsWith("select")?(p(),v(B,{key:2},[i("span",Of,m($("chevron-left")),1),i("span",If,m($("chevron-right")),1)],64)):u.value==="stepper"?(p(),v("span",Mf,[i("span",Lf,m($("minus")||"−"),1),i("b",null,m(G.value),1),i("span",Df,m($("plus")||"+"),1)])):u.value==="mode"?(p(),v(B,{key:4},[i("span",Ff,m($("power")),1),i("span",Nf,m($("fire")),1),i("span",Rf,m($("snowflake")),1)],64)):u.value==="volume"?(p(),v(B,{key:5},[i("span",{class:"range",style:Ae(z.value)},null,4),i("span",Hf,m($("volume-high")),1)],64)):u.value==="playback"?(p(),v(B,{key:6},[i("span",Uf,m($("skip-previous")),1),i("span",jf,m($(I.value?"pause":"play")||$("play")),1),i("span",Bf,m($("skip-next")),1)],64)):u.value==="buttons"&&h.value==="cover"?(p(),v(B,{key:7},[i("span",Vf,m($("arrow-expand-horizontal")),1),i("span",Wf,m($("stop")),1),i("span",Kf,m($("arrow-collapse-horizontal")),1)],64)):u.value==="buttons"&&h.value==="vacuum"?(p(),v(B,{key:8},[i("span",qf,m($("play")),1),i("span",Yf,m($("stop")),1),i("span",zf,m($("home-map-marker")),1)],64)):u.value==="buttons"&&h.value==="timer"?(p(),v(B,{key:9},[i("span",Jf,m($("play")),1),i("span",Gf,m($("close")),1)],64)):u.value==="run"?(p(),v("span",Xf,m({scene:"Activate",script:"Run"}[h.value]||"Press"),1)):(p(),v("span",{key:11,class:"range",style:Ae({background:`linear-gradient(to right, #2196f3 ${M.value}%, #d3e8fb ${M.value}%)`})},null,4))])):j("",!0)],64)):(p(),v(B,{key:4},[i("span",{class:le(["head",{top:c.value==="watch"}])},[i("span",{class:le(["ic mdi",{lit:A.value}])},m(y(ge)(y(Un)(e.tile))),3),i("span",Zf,[i("span",Qf,m(n.value),1),l.value?(p(),v("span",ep,"Page "+m(l.value)+" ›",1)):c.value!=="watch"&&Oe.value?(p(),v("span",{key:1,class:le(["st",{off:V.value}])},m(Oe.value),3)):j("",!0)])],2),c.value==="watch"?(p(),v("span",tp,[X(m(G.value),1),b.value&&!V.value?(p(),v("small",np,m(b.value),1)):j("",!0)])):j("",!0),e.tile.options?.inline==="slider"?(p(),v("span",{key:1,class:"mini-slider",style:Ae(P.value)},null,4)):j("",!0)],64)),E.value?(p(),v("button",{key:5,type:"button",class:"remove",title:"Remove tile","aria-label":`Remove ${n.value}`,onClick:ue[0]||(ue[0]=ut(Me=>y(Ia)(e.tile),["stop"]))},"✕",8,sp)):j("",!0)],46,Wd)),[[y(Na),{kind:"tile",tile:e.tile}]])}}),ip=["viewBox","aria-label"],lp=["y"],ap=["cx","cy","r","stroke","stroke-width"],rp=["x1","y1","x2","y2","stroke","stroke-width"],up=["x","y","fill"],cp=["x","y"],Ua=Te({__name:"TopbarSvg",props:{items:{},nameText:{},single:{type:Boolean}},setup(e,{expose:t}){const n=e,s=k(()=>{d.fontsVersion,d.now,d.topbarPreviews;const x=n.items||Ht();return ya(x,Ea.value,n.nameText??(d.layout?.title||"Home"),Vt)}),o=k(()=>s.value.metrics),l=k(()=>n.single?Math.round(o.value.text*1.4):o.value.top+Math.round(o.value.name*.45)),a=k(()=>n.single?Math.round(o.value.text*1.05):o.value.top),r=k(()=>{if(!n.single)return s.value.placed;const x=s.value.parts[0];return x&&(x.x=0),x?[x]:[]}),c=k(()=>n.single?`-2 0 ${Math.max(1,r.value[0]?.width||1)+4} ${l.value}`:`0 0 ${o.value.width} ${l.value}`),f=k(()=>a.value+(s.value.zero.top+s.value.zero.bottom)/2),u=k(()=>$c(s.value.nameText,s.value.fonts.name,Math.min(s.value.natural,s.value.nameRoom))),h=k(()=>new Date(d.now));function w(x){const S=h.value;return[[(S.getHours()%12+S.getMinutes()/60)*30,x*.24],[S.getMinutes()*6,x*.34]].map(([L,J])=>{const C=L*Math.PI/180;return{x2:J*Math.sin(C),y2:-J*Math.cos(C)}})}const $=x=>x.x-x.icon.ink.left,D=x=>(x.icon?x.x+(x.icon.ink.right-x.icon.ink.left)+s.value.gaps.icon:x.x)-x.text.ink.left,E=k(()=>`${(Math.max(1,r.value[0]?.width||1)+4)*(26/o.value.icon)}px`);return t({lay:s}),(x,S)=>(p(),v("svg",{viewBox:c.value,role:"img","aria-label":e.single?"How it looks on the screen":`Top bar: ${s.value.nameText}`,style:Ae(e.single?{width:E.value}:void 0)},[e.single?j("",!0):(p(),v("text",{key:0,x:"0",y:o.value.top,fill:"#1b1b1b",style:Ae({font:s.value.fonts.name})},m(u.value),13,lp)),i("g",null,[(p(!0),v(B,null,ie(r.value,L=>(p(),v(B,{key:L.index},[L.dial?(p(),v(B,{key:0},[i("circle",{cx:L.x+L.dial/2,cy:f.value,r:L.dial/2-Math.max(1,Math.round(L.dial/10))/2,fill:"none",stroke:L.view.color||"#46525e","stroke-width":Math.max(1,Math.round(L.dial/10))},null,8,ap),(p(!0),v(B,null,ie(w(L.dial),(J,C)=>(p(),v("line",{key:C,x1:L.x+L.dial/2,y1:f.value,x2:L.x+L.dial/2+J.x2,y2:f.value+J.y2,stroke:L.view.color||"#46525e","stroke-width":Math.max(1,Math.round(L.dial*.075)),"stroke-linecap":"round"},null,8,rp))),128))],64)):(p(),v(B,{key:1},[L.icon?(p(),v("text",{key:0,x:$(L),y:f.value-(L.icon.ink.top+L.icon.ink.bottom)/2,fill:L.view.color||"#46525e",style:Ae({font:s.value.fonts.icon})},m(L.icon.glyph),13,up)):j("",!0),L.text?(p(),v("text",{key:1,x:D(L),y:a.value,fill:"#46525e",style:Ae({font:s.value.fonts.text})},m(L.text.value),13,cp)):j("",!0)],64))],64))),128))])],12,ip))}}),dp={class:"page"},fp={class:"page-label"},pp={key:1},hp={class:"tiles"},vp=["data-slot","aria-label","onClick"],mp=Te({__name:"DevicePage",props:{page:{},entries:{},pages:{},moving:{}},setup(e){const t=e,n=k(()=>new Map(t.entries.map(f=>[f.slot,f]))),s=k(()=>new Set(t.entries.flatMap(f=>hn(f.slot,Ge(f.tile)).slice(1)))),o=k(()=>Array.from({length:Le},(f,u)=>t.page*Le+u).filter(f=>!s.value.has(f))),l=k(()=>t.pages>1&&!t.entries.some(f=>En(f.slot)===t.page)),a=k(()=>d.inspector?.kind==="bar"||d.inspector?.kind==="bar-add"),r=k(()=>t.entries.filter(f=>En(f.slot)===t.page).reduce((f,u)=>f+ui(Ge(u.tile)),0));function c(f){const u=d.insertAt===f;d.insertAt=u?-1:f,d.insertAt>=0&&document.querySelector("#search")?.focus()}return(f,u)=>(p(),v("div",dp,[i("div",fp,[i("span",null,"Page "+m(e.page+1),1),l.value?(p(),v("button",{key:0,type:"button",class:"btn mini",title:"The pages after this one shift up one slot",onClick:u[0]||(u[0]=h=>y(jc)(e.page))},"Remove page")):(p(),v("span",pp,m(r.value)+" / "+m(y(Le)),1))]),i("div",{class:le(["device",{cyd:!y(ci)}])},[i("div",{class:le(["bar-wrap",{selected:a.value}]),title:"Edit top bar",role:"button",tabindex:"0",onClick:u[1]||(u[1]=h=>y(Ot)(0)),onKeydown:u[2]||(u[2]=va(ut(h=>y(Ot)(0),["prevent"]),["enter"]))},[we(Ua)],34),i("div",hp,[(p(!0),v(B,null,ie(o.value,h=>(p(),v(B,{key:h},[n.value.get(h)?(p(),Je(op,{key:0,tile:n.value.get(h).tile,slot:h,placeholder:n.value.get(h).tile===e.moving},null,8,["tile","slot","placeholder"])):(p(),v("button",{key:1,type:"button",class:le(["cell",{"insert-here":y(d).insertAt===h}]),"data-slot":h,title:"Empty slot. Click to add a tile here, or drag one over.","aria-label":`Empty slot ${h%y(Le)+1} on page ${e.page+1}: add the next tile here`,onClick:w=>c(h)},[u[3]||(u[3]=i("span",null,"+",-1)),i("small",null,m(y(d).insertAt===h?"Next tile goes here":"Empty"),1)],10,vp))],64))),128))])],2)]))}}),yp={class:"library",id:"library"},gp={class:"lib-head"},bp={class:"lib-title"},wp={class:"filters",id:"filters"},_p=["aria-pressed","onClick"],kp={class:"lib-row"},$p=["value"],xp=["aria-pressed"],Sp={class:"lib-list",id:"results","aria-live":"polite"},Cp=["title","disabled","onClick"],Tp={class:"tx"},Ap={key:0,class:"hint"},Ep={key:1,class:"hint"},Pp={class:"lib-foot"},Op=Te({__name:"Library",setup(e){const t=[["","All"],["light","Lights"],["climate","Climate"],["switch","Switches"],["binary_sensor","Status"],["button","Actions"],["script","Scripts"],["fan","Fans"],["cover","Covers"],["scene","Scenes"],["vacuum","Vacuum"],["sensor","Sensors"],["media_player","Media"],["weather","Weather"],["number","Values"],["select","Selects"],["person","People"],["timer","Timers"],["screen","Screen"]],n={switch:"input_boolean",number:"input_number",select:"input_select",weather:"sun",button:"input_button"},s=k(()=>new Set(d.layout?.tiles.map(f=>f.entity)||[])),o=k(()=>[...new Set(d.inventory.entities.map(f=>f.area).filter(f=>!!f))].sort((f,u)=>f.localeCompare(u))),l=k(()=>{const f=d.search.toLocaleLowerCase(),u=d.filter,h=d.room;return[...d.inventory.builtin||[],...d.inventory.entities].filter(w=>w.tile!==!1&&(ci.value||!["camera","image"].includes(w.id.split(".")[0]))&&(!u||w.id.startsWith(u+".")||n[u]===w.id.split(".")[0])&&(!h||w.area===h)&&(!d.hidePlaced||!s.value.has(w.id))&&`${w.name} ${w.id} ${w.device||""} ${w.area||""}`.toLocaleLowerCase().includes(f))}),a=k(()=>(d.layout?.tiles.length||0)>=vn.value),r=k(()=>d.inventory.entities.length),c=f=>{const u=f.id.split(".")[0];return f.state==="unavailable"||f.state==="unknown"?"gone":["light","switch","input_boolean","fan"].includes(u)&&f.state==="on"?"on":""};return(f,u)=>(p(),v("aside",yp,[i("div",gp,[i("div",bp,[u[3]||(u[3]=X("Library ",-1)),i("small",null,m(r.value)+" entities",1)]),ye(i("input",{id:"search","onUpdate:modelValue":u[0]||(u[0]=h=>y(d).search=h),type:"search",placeholder:"Search by name, device, or room…",autocomplete:"off","aria-label":"Add an entity"},null,512),[[De,y(d).search]]),i("div",wp,[(p(),v(B,null,ie(t,([h,w])=>i("button",{key:h,type:"button","aria-pressed":y(d).filter===h?"true":"false",onClick:$=>y(d).filter=h},[h?(p(),v("span",{key:0,class:"domain-icon",style:Ae({color:y(wt)(h+".")[2],background:y(wt)(h+".")[3]}),"aria-hidden":"true"},m(y(wt)(h+".")[1]),5)):j("",!0),X(m(w),1)],8,_p)),64))]),i("div",kp,[ye(i("select",{id:"room","onUpdate:modelValue":u[1]||(u[1]=h=>y(d).room=h),"aria-label":"Room"},[u[4]||(u[4]=i("option",{value:""},"All rooms",-1)),(p(!0),v(B,null,ie(o.value,h=>(p(),v("option",{key:h,value:h},m(h),9,$p))),128))],512),[[pn,y(d).room]]),i("button",{type:"button",class:"chip-toggle",id:"hide-placed","aria-pressed":y(d).hidePlaced?"true":"false",title:"Hide what is already on this screen",onClick:u[2]||(u[2]=h=>y(d).hidePlaced=!y(d).hidePlaced)},"Hide placed",8,xp)])]),i("div",Sp,[(p(!0),v(B,null,ie(l.value.slice(0,80),h=>ye((p(),v("button",{key:h.id,type:"button",class:"ent",title:h.id,disabled:s.value.has(h.id)||a.value,onClick:w=>y(Oa)(h.id)},[i("span",{class:le(["av mdi",c(h)]),style:Ae({color:y(wt)(h.id)[2],background:y(wt)(h.id)[3]})},m(y(d).inventory.icons?y(ge)(y(en)(h.id)):y(wt)(h.id)[1]),7),i("span",Tp,[i("b",null,m(h.name),1),i("small",null,m([y(wt)(h.id)[0],h.area,h.device].filter(Boolean).join(" · ")),1)]),i("span",{class:le(["add",{done:s.value.has(h.id)}])},m(s.value.has(h.id)?"✓":"+"),3)],8,Cp)),[[y(Na),{kind:"entity",id:h.id}]])),128)),l.value.length?l.value.length>80?(p(),v("p",Ep,m(l.value.length)+" results. Keep typing to narrow it down.",1)):j("",!0):(p(),v("p",Ap,m(y(d).hidePlaced&&!y(d).search&&!y(d).filter&&!y(d).room?"Everything here is already on this screen.":"No entities found. Try a different name, room or filter."),1))]),i("div",Pp,m(a.value?`This screen holds ${y(vn)} tiles${y(vn)<48?"; update its firmware for 48":""}.`:"Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything."),1)]))}}),Ip={class:"canvas-head"},Mp={id:"count"},Lp={key:0,id:"no-tiles"},Dp={key:1},Fp={key:2,id:"positions-hint",class:"warn"},Np={class:"pages",id:"layout-preview","aria-label":"Screen layout"},Rp={class:"page-label"},Hp=["tabindex"],Up=Te({__name:"LayoutView",setup(e){const t=k(()=>d.layout),n=k(()=>d.drag.preview||nt(t.value)),s=k(()=>jt(n.value,t.value.pages)),o=k(()=>Bc()),l=k(()=>s.valueTc(t.value.tiles)&&!Kt(0,2,26)?`Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${Tt.value?.firmware||"unknown"}) shifts the tiles up to the first free slot until that update.`:"");function r(c){c.target.closest(".device, .page-label, .canvas-head")||d.inspector&&Be()}return(c,f)=>(p(),v(B,null,[i("div",{class:"canvas",id:"canvas",onClick:r},[i("div",Ip,[i("b",Mp,m(s.value)+" "+m(s.value===1?"page":"pages")+" · "+m(t.value.tiles.length)+" / "+m(y(vn))+" tiles",1),t.value.tiles.length?(p(),v("span",Dp,"Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.")):(p(),v("span",Lp,"Add your first light, scene, or device from the library.")),a.value?(p(),v("span",Fp,m(a.value),1)):j("",!0)]),i("div",Np,[(p(!0),v(B,null,ie(o.value,u=>(p(),Je(mp,{key:u,page:u-1,entries:n.value,pages:s.value,moving:y(d).drag.moving},null,8,["page","entries","pages","moving"]))),128)),i("div",{class:le(["page ghost",{disabled:!l.value}])},[i("div",Rp,[i("span",null,"Page "+m(o.value+1),1)]),i("div",{class:le(["device",{cyd:y(Tt)?.board!=="guition"}]),id:"add-page",role:"button",tabindex:l.value?0:-1,onClick:f[0]||(f[0]=u=>l.value&&y(Qi)()),onKeydown:f[1]||(f[1]=va(ut(u=>l.value&&y(Qi)(),["prevent"]),["enter"]))},m(l.value?"+ Add page":`${y(gn)} pages is the most`),43,Hp)],2)])]),we(Op)],64))}}),jp={class:"status",id:"settings-status",role:"status"},Bp={key:0,class:"set-grid",id:"settings-groups"},Vp={class:"mdi"},Wp=["data-setting","title","onClick"],Kp=["id"],qp={class:"s-control"},Yp=["id","aria-checked","aria-labelledby","disabled","onClick"],zp=["aria-labelledby"],Jp=["aria-pressed","disabled","onClick"],Gp={key:2,class:"step"},Xp=["aria-label","disabled","onPointerdown","onClick"],Zp={class:"mdi"},Qp=["id","aria-labelledby"],eh=["aria-label","disabled","onPointerdown","onClick"],th={class:"mdi"},nh=Te({__name:"SettingsTab",setup(e){const t=k(()=>di()),n=k(()=>Pn()),s=k(()=>t.value?.owner==="screen"&&!Tt.value?.online),o=k(()=>Gc.map(D=>({...D,rows:D.rows.filter(E=>t.value?.keys.includes(E.key))})).filter(D=>D.rows.length)),l=D=>s.value||!!t.value?.unavailable.includes(D.key),a=D=>D.needs?!!n.value[D.needs]:!0,r=k(()=>s.value?"This screen is offline. Its settings can change once it's back.":d.settingPending?"Saving…":t.value?.owner==="screen"?"Changes apply on the screen at once, and show up here when they change there.":"Changes apply at once. Firmware 0.2.49 lets the screen keep them itself."),c=(D,E)=>{const x=n.value[D.key];return l(D)||!a(D)||x===null||x===void 0?!0:D.kind!=="moment"&&ol(D,x,E,!1,n.value)===x},f=new WeakMap;function u(D,E,x){const S=Pn(),L=ol(D,S[D.key],E,x,S);L!==S[D.key]&&Bn(D.key,L,600)}function h(D,E,x){const S=D.currentTarget;if(S.disabled||D.button!==0)return;const L={timer:0,held:!1};f.set(S,L);let J=0;L.timer=window.setTimeout(function C(){L.held=!0,J+=1,u(E,x,J>5),L.timer=window.setTimeout(C,180)},450)}function w(D){const E=f.get(D.currentTarget);E&&clearTimeout(E.timer)}function $(D,E,x){const S=f.get(D.currentTarget);if(S?.held){S.held=!1;return}u(E,x,!1)}return(D,E)=>(p(),v("div",{class:le(["settings",{offline:s.value}]),id:"general-settings"},[i("span",jp,m(r.value),1),t.value?(p(),v("div",Bp,[(p(!0),v(B,null,ie(o.value,x=>(p(),v("section",{key:x.title,class:"set-card"},[i("h4",null,[i("span",Vp,m(y(ge)(x.icon)),1),X(m(x.title),1)]),(p(!0),v(B,null,ie(x.rows,S=>(p(),v("div",{key:S.key,class:le(["srow",[`setting-${S.kind}`,{inactive:!a(S)||l(S)}]]),"data-setting":S.key,title:l(S)&&!s.value?"This entity is off in Home Assistant, or the screen is restarting.":"",onClick:L=>S.kind==="toggle"&&L.target.closest(".srow")===L.currentTarget&&!L.target.closest("button")&&!l(S)&&y(Bn)(S.key,!n.value[S.key],150)},[i("span",{class:"s-label",id:`setting-label-${S.key}`},m(S.label),9,Kp),i("div",qp,[S.kind==="toggle"?(p(),v("button",{key:0,type:"button",class:le(["switch",{unknown:n.value[S.key]===null||n.value[S.key]===void 0}]),role:"switch",id:`setting-${S.key}`,"aria-checked":n.value[S.key]?"true":"false","aria-labelledby":`setting-label-${S.key}`,disabled:l(S),onClick:ut(L=>y(Bn)(S.key,!n.value[S.key],150),["stop"])},null,10,Yp)):S.kind==="choice"?(p(),v("div",{key:1,class:"seg",role:"group","aria-labelledby":`setting-label-${S.key}`},[(p(!0),v(B,null,ie(S.options,([L,J])=>(p(),v("button",{key:String(L),type:"button","aria-pressed":n.value[S.key]===L?"true":"false",disabled:l(S),onClick:C=>y(Bn)(S.key,L,150)},m(J),9,Jp))),128))],8,zp)):(p(),v("div",Gp,[i("button",{type:"button","aria-label":`${S.label} lower`,disabled:c(S,-1),onPointerdown:L=>h(L,S,-1),onPointerup:w,onPointercancel:w,onPointerleave:w,onClick:L=>$(L,S,-1)},[i("span",Zp,m(y(ge)("F0374")),1)],40,Xp),i("output",{id:`setting-${S.key}`,"aria-labelledby":`setting-label-${S.key}`},m(y(ed)(S,n.value)),9,Qp),i("button",{type:"button","aria-label":`${S.label} higher`,disabled:c(S,1),onPointerdown:L=>h(L,S,1),onPointerup:w,onPointercancel:w,onPointerleave:w,onClick:L=>$(L,S,1)},[i("span",th,m(y(ge)("F0415")),1)],40,eh)]))])],10,Wp))),128))]))),128))])):j("",!0)],2))}}),sh={class:"seg wrap",role:"group"},oh=["disabled","aria-pressed","onClick"],it=Te({__name:"Segmented",props:{choices:{},value:{},disabled:{type:Boolean}},emits:["pick"],setup(e,{emit:t}){const n=t;return(s,o)=>(p(),v("div",sh,[(p(!0),v(B,null,ie(e.choices,([l,a])=>(p(),v("button",{key:String(l),type:"button",disabled:e.disabled,"aria-pressed":String(l)===String(e.value)?"true":"false",onClick:r=>n("pick",l)},m(a),9,oh))),128))]))}}),ih={class:"f"},lh={key:0},ah={key:1,class:"warn"},rh=["aria-expanded"],uh={class:"tx"},ch={key:0,class:"mono"},dh={class:"link"},fh={key:0,class:"picker"},ph={class:"action-list"},hh=["aria-pressed","onClick"],vh={key:0,class:"hint"},mh={key:1,class:"warn"},yh={class:"f-label"},gh=["aria-label","value","onChange"],bh=["value"],wh={key:3,class:"action-number"},_h=["aria-label","value","onInput"],kh=["onClick"],$h={key:4,class:"action-number"},xh=["aria-label","min","max","step","value","placeholder","onInput"],Sh={key:0},Ch=["aria-label","value","placeholder","onInput"],Th={key:6},Ah={key:0,class:"warn"},Eh={key:3},Ph=Te({__name:"ActionPicker",props:{tile:{}},setup(e){const t=e,n=k(()=>d.entityActions[t.tile.entity]),s=k(()=>t.tile.options?.action),o=k(()=>n.value?.find(C=>C.action===s.value?.action)),l=k(()=>d.actionPickerOpen||!s.value),a=k(()=>{const C=d.actionSearch.trim().toLocaleLowerCase();return(n.value||[]).filter(V=>`${V.name} ${V.action} ${V.description}`.toLocaleLowerCase().includes(C))});n.value===void 0&&Rc(t.tile.entity);function r(C){const V=C.action===s.value?.action;t.tile.options={...t.tile.options,action:V?s.value:{action:C.action}},d.actionPickerOpen=!1,Xe()}function c(C,V){const I={...t.tile.options?.action?.data||{}};V===void 0?delete I[C]:I[C]=V,t.tile.options={...t.tile.options,action:{action:s.value.action,...Object.keys(I).length?{data:I}:{}}},Xe()}const f=k(()=>{if(!o.value)return[];const C=t.tile.options?.action?.data||{};return o.value.fields.filter(V=>V.required&&C[V.key]===void 0).map(V=>V.name)}),u=C=>Object.keys(C.selector||{})[0]||"text",h=C=>(C.selector||{})[u(C)]||{},w=C=>C.example===void 0?"":typeof C.example=="string"?C.example:JSON.stringify(C.example);function $(C){const V=h(C),I=u(C);return Array.isArray(C.options)?C.options.map(A=>[A,A]):I==="select"&&Array.isArray(V.options)&&!V.multiple?V.options.map(A=>A&&typeof A=="object"?[String(A.value),String(A.label??A.value)]:[String(A),String(A)]):null}const D=C=>"#"+C.map(V=>Math.max(0,Math.min(255,Number(V)||0)).toString(16).padStart(2,"0")).join(""),E=C=>Array.isArray(C)&&C.length===3?D(C):"#ffffff",x=C=>C===void 0?"":typeof C=="string"?C:JSON.stringify(C);function S(C,V){const I=V.trim();if(!I)return c(C.key,void 0);if(u(C)==="text")return c(C.key,V);try{c(C.key,JSON.parse(I))}catch{c(C.key,V)}}function L(C,V){c(C.key,V===""||!Number.isFinite(Number(V))?void 0:Number(V))}const J=C=>h(C).unit_of_measurement||u(C)==="color_temp"&&h(C).unit||"";return(C,V)=>(p(),v("div",ih,[V[3]||(V[3]=i("span",{class:"f-label"},"Action",-1)),n.value===void 0?(p(),v("small",lh,"Asking Home Assistant which actions this entity has…")):n.value===null?(p(),v("small",ah,"Home Assistant isn't answering right now. Try again in a moment.")):(p(),v(B,{key:2},[i("button",{type:"button",class:"row","aria-expanded":l.value?"true":"false",onClick:V[0]||(V[0]=I=>y(d).actionPickerOpen=!l.value)},[i("span",uh,[i("b",null,m(o.value?o.value.name:s.value?s.value.action:"Choose an action"),1),s.value?(p(),v("small",ch,m(s.value.action),1)):j("",!0)]),i("span",dh,m(l.value?"Close":"Change"),1)],8,rh),l.value?(p(),v("div",fh,[ye(i("input",{"onUpdate:modelValue":V[1]||(V[1]=I=>y(d).actionSearch=I),type:"search",placeholder:"Search, for example toggle, position, or play","aria-label":"Search for an action"},null,512),[[De,y(d).actionSearch]]),i("div",ph,[(p(!0),v(B,null,ie(a.value,I=>(p(),v("button",{key:I.action,type:"button",class:"action-choice","aria-pressed":I.action===s.value?.action?"true":"false",onClick:A=>r(I)},[i("strong",null,m(I.name),1),i("small",null,m(I.description?`${I.action} · ${I.description}`:I.action),1)],8,hh))),128)),a.value.length?j("",!0):(p(),v("p",vh,"No action found."))])])):j("",!0),s.value&&!o.value?(p(),v("small",mh,"Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.")):j("",!0),o.value&&!l.value?(p(),v(B,{key:2},[(p(!0),v(B,null,ie(o.value.fields,I=>(p(),v("div",{key:I.key,class:"f"},[i("span",yh,m(I.required?I.name:`${I.name} (optional)`),1),u(I)==="boolean"?(p(),Je(it,{key:0,choices:[["","Not set"],["true","On"],["false","Off"]],value:s.value?.data?.[I.key]===void 0?"":String(s.value?.data?.[I.key]),onPick:A=>c(I.key,A===""?void 0:A==="true")},null,8,["value","onPick"])):$(I)&&$(I).length<=4?(p(),Je(it,{key:1,choices:[["","Not set"],...$(I)],value:s.value?.data?.[I.key]===void 0?"":String(s.value?.data?.[I.key]),onPick:A=>c(I.key,A===""?void 0:A)},null,8,["choices","value","onPick"])):$(I)?(p(),v("select",{key:2,"aria-label":I.name,value:String(s.value?.data?.[I.key]??""),onChange:A=>c(I.key,A.target.value===""?void 0:A.target.value)},[V[2]||(V[2]=i("option",{value:""},"Not set",-1)),(p(!0),v(B,null,ie($(I),([A,b])=>(p(),v("option",{key:A,value:A},m(b),9,bh))),128))],40,gh)):u(I)==="color_rgb"?(p(),v("div",wh,[i("input",{type:"color","aria-label":I.name,value:E(s.value?.data?.[I.key]),onInput:A=>c(I.key,[1,3,5].map(b=>parseInt(A.target.value.slice(b,b+2),16)))},null,40,_h),i("button",{type:"button",class:"btn quiet mini",onClick:A=>c(I.key,void 0)},"Not set",8,kh)])):u(I)==="number"||u(I)==="color_temp"?(p(),v("div",$h,[i("input",{type:"number","aria-label":I.name,min:h(I).min,max:h(I).max,step:h(I).step==="any"?"any":h(I).step,value:s.value?.data?.[I.key]??"",placeholder:w(I),onInput:A=>L(I,A.target.value)},null,40,xh),J(I)?(p(),v("span",Sh,m(J(I)),1)):j("",!0)])):(p(),v("input",{key:5,type:"text","aria-label":I.name,value:x(s.value?.data?.[I.key]),placeholder:w(I),onInput:A=>S(I,A.target.value)},null,40,Ch)),I.description?(p(),v("small",Th,m(I.description),1)):j("",!0)]))),128)),f.value.length?(p(),v("small",Ah,"Home Assistant needs "+m(f.value.join(", "))+".",1)):j("",!0)],64)):j("",!0),y(Kt)(0,2,58)?j("",!0):(p(),v("small",Eh,"The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic."))],64))]))}}),Oh={class:"f"},Ih=["aria-expanded"],Mh={class:"av mdi"},Lh={class:"tx"},Dh={class:"link"},Fh={key:0,class:"picker"},Nh=["aria-pressed","title"],Rh={class:"mdi"},Hh=["aria-pressed"],Uh={class:"icon-list"},jh={class:"icon-grid"},Bh=["title","aria-label","aria-pressed","onClick"],Vh={class:"mdi"},Wh={key:0,class:"hint"},Kh={key:1},ja=Te({__name:"IconPicker",props:{selected:{},automatic:{},autoLabel:{},allowNone:{type:Boolean},note:{}},emits:["pick"],setup(e,{emit:t}){const n=e,s=t,o=de(""),l=k(()=>fs(n.selected)),a=k(()=>n.selected==="none"?"":ge(l.value?.cp||n.automatic)),r=k(()=>n.selected==="none"?"No icon":l.value?.label||n.autoLabel),c=k(()=>{const f=o.value.trim().toLocaleLowerCase();return(d.inventory.icons?.groups||[]).map(u=>({label:u.label,icons:u.icons.filter(h=>!f||`${h.label} ${h.name.replaceAll("-"," ")} ${u.label}`.toLocaleLowerCase().includes(f))})).filter(u=>u.icons.length)});return(f,u)=>(p(),v("div",Oh,[u[5]||(u[5]=i("span",{class:"f-label"},"Icon",-1)),i("button",{type:"button",class:"row","aria-expanded":y(d).iconPickerOpen?"true":"false",onClick:u[0]||(u[0]=h=>y(d).iconPickerOpen=!y(d).iconPickerOpen)},[i("span",Mh,m(a.value),1),i("span",Lh,[i("b",null,m(r.value),1)]),i("span",Dh,m(y(d).iconPickerOpen?"Close":"Change"),1)],8,Ih),y(d).iconPickerOpen?(p(),v("div",Fh,[ye(i("input",{"onUpdate:modelValue":u[1]||(u[1]=h=>o.value=h),type:"search",placeholder:"Search, for example lamp, music, or door","aria-label":"Search for an icon"},null,512),[[De,o.value]]),i("button",{type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="auto"?"true":"false",title:e.autoLabel,onClick:u[2]||(u[2]=h=>s("pick","auto"))},[i("span",Rh,m(y(ge)(e.automatic)),1),i("span",null,m(e.autoLabel),1)],8,Nh),e.allowNone?(p(),v("button",{key:0,type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="none"?"true":"false",title:"No icon",onClick:u[3]||(u[3]=h=>s("pick","none"))},[...u[4]||(u[4]=[i("span",{class:"mdi"},null,-1),i("span",null,"No icon, text only",-1)])],8,Hh)):j("",!0),i("div",Uh,[(p(!0),v(B,null,ie(c.value,h=>(p(),v("section",{key:h.label},[i("small",null,m(h.label),1),i("div",jh,[(p(!0),v(B,null,ie(h.icons,w=>(p(),v("button",{key:w.name,type:"button",class:"icon-choice",title:w.label,"aria-label":`Icon: ${w.label}`,"aria-pressed":e.selected===w.name?"true":"false",onClick:$=>s("pick",w.name)},[i("span",Vh,m(y(ge)(w.cp)),1)],8,Bh))),128))])]))),128)),c.value.length?j("",!0):(p(),v("p",Wh,"No icon found."))])])):j("",!0),e.note?(p(),v("small",Kh,m(e.note),1)):j("",!0)]))}}),qh={class:"dr-head"},Yh={class:"tx"},zh={class:"mono"},Jh={class:"dr-body"},Gh={class:"f"},Xh=["value","placeholder"],Zh={key:1,class:"f"},Qh={key:2,class:"f"},ev={key:0,class:"warn"},tv={class:"f"},nv={key:0},sv={key:3,class:"f"},ov={key:4,class:"f"},iv={key:6,class:"f"},lv={key:0,class:"warn"},av={key:7,class:"f"},rv={class:"f"},uv={class:"sw"},cv=["aria-label","aria-pressed","onClick"],dv={class:"dr-foot"},fv=Te({__name:"TileInspector",props:{tile:{}},setup(e){const t=e,n=k(()=>t.tile.entity.split(".")[0]),s=k(()=>ds(t.tile.entity)),o=k(()=>Xs(t.tile.entity)),l=Array.from({length:gn},(M,P)=>[P+1,String(P+1)]),a=k(()=>o.value?[["single","Normal"],["wide","Double-width"]]:[["single","Normal"],["wide","Double-width"],["full","Full page"]]),r=k(()=>o.value?"":Kt(0,2,62)?"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."),c=k(()=>d.capabilities[t.tile.entity]),f=(M,P)=>t.tile.options?.[M]??P,u=k(()=>f("display",n.value==="screen"?"digital":"standard")),h=k(()=>{const M=n.value==="screen"?[["digital","Digital clock"],["analog","Analog clock"]]:[["standard","Name and status"],["watch","Large value"]],P=c.value;return n.value==="weather"&&(!P||P.displays.includes("forecast")||u.value==="forecast")&&M.push(["forecast","Weather forecast"]),n.value==="sensor"&&(!P||P.displays.includes("graph")||u.value==="graph")&&M.push(["graph","Graph"]),n.value==="sun"&&M.push(["sunpath","Sun path"]),M}),w=k(()=>{const M=c.value;return M&&u.value==="graph"&&!M.displays.includes("graph")?"Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.":M&&u.value==="forecast"&&!M.displays.includes("forecast")?"This weather service has no daily forecast in Home Assistant. Choose another display.":""}),$=k(()=>f("size","single")),D=k(()=>d.inventory.controls?.[n.value]),E=k(()=>f("controls",$.value==="full"?"none":D.value?.default)),x=k(()=>{const M=c.value;return(D.value?.choices||[]).filter(P=>!M||P.key==="none"||P.key===E.value||M.controls.includes(P.key)).map(P=>[P.key,P.label])}),S=k(()=>{const M=c.value;return M&&E.value!=="none"&&!M.controls.includes(E.value)?{text:"Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.",warn:!0}:{text:Kt(0,2,19)?$.value==="full"?"At the bottom of the full-page tile; a tap anywhere else works as set below.":"On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.":"The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.",warn:!1}}),L=k(()=>f("tap","auto")),J=k(()=>{const M=[["auto","Automatic"],["detail","Open control"],["none","View only"]];return((c.value?c.value.toggle:Xi.includes(n.value))||L.value==="toggle")&&M.push(["toggle","On / off"]),M.push(["action","Perform action"]),M}),C=k(()=>L.value==="toggle"&&c.value&&!c.value.toggle?{text:"Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.",warn:!0}:L.value==="toggle"&&!Xi.includes(n.value)&&!Kt(0,2,58)?{text:"The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.",warn:!1}:L.value==="toggle"?{text:"Hold the tile to open its card.",warn:!1}:null),V=k(()=>f("inline","none")),I=k(()=>Lc.includes(n.value)&&(!c.value||c.value.inline||V.value==="slider")),A=k(()=>V.value==="slider"&&c.value&&!c.value.inline),b=k(()=>f("history_hours",24)),T=k(()=>Object.entries(d.inventory.backgrounds||{})),ne=k(()=>!!d.inventory.entities.find(M=>M.id===t.tile.entity)?.icon),Se=k(()=>!!d.inventory.icons&&(n.value!=="screen"||o.value>0)&&!["forecast","sunpath"].includes(u.value));function Oe(M){t.tile.name=M,Xe()}function G(){d.inspector={kind:"inspect",entity:t.tile.entity}}return(M,P)=>(p(),v(B,null,[i("div",qh,[i("span",{class:"av mdi",style:Ae({color:y(wt)(e.tile.entity)[2],background:y(wt)(e.tile.entity)[3]})},m(y(ge)(y(Un)(e.tile))),5),i("span",Yh,[i("b",null,m(e.tile.name||s.value),1),i("small",zh,m(e.tile.entity),1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:P[0]||(P[0]=(...z)=>y(Be)&&y(Be)(...z))},"✕")]),i("div",Jh,[i("div",Gh,[P[11]||(P[11]=i("label",{class:"f-label",for:"tile-name"},"Name on the screen",-1)),i("input",{id:"tile-name",value:e.tile.name,placeholder:s.value,maxlength:"60",onInput:P[1]||(P[1]=z=>Oe(z.target.value))},null,40,Xh)]),Se.value?(p(),Je(ja,{key:0,selected:e.tile.options?.icon||"auto",automatic:y(en)(e.tile.entity),"auto-label":`Automatic (${ne.value?"from Home Assistant":"default"})`,note:y(Kt)(0,2,18)?"":"The screen shows a chosen icon from firmware 0.2.18.",onPick:P[2]||(P[2]=z=>y(Bt)(e.tile,"icon",z))},null,8,["selected","automatic","auto-label","note"])):j("",!0),o.value?(p(),v("div",Zh,[P[12]||(P[12]=i("span",{class:"f-label"},"Goes to page",-1)),we(it,{choices:y(l),value:o.value,onPick:P[3]||(P[3]=z=>y(Vc)(e.tile,Number(z)))},null,8,["choices","value"]),i("small",null,m(y(Kt)(0,2,62)?"A tap on the tile opens that page.":"Navigation tiles need firmware 0.2.62: press Update on the screen first."),1)])):(p(),v("div",Qh,[P[13]||(P[13]=i("span",{class:"f-label"},"Display",-1)),we(it,{choices:h.value,value:u.value,onPick:P[4]||(P[4]=z=>y(Bt)(e.tile,"display",z))},null,8,["choices","value"]),w.value?(p(),v("small",ev,m(w.value),1)):j("",!0)])),i("div",tv,[P[14]||(P[14]=i("span",{class:"f-label"},"Size",-1)),we(it,{choices:a.value,value:$.value,onPick:P[5]||(P[5]=z=>y(Bt)(e.tile,"size",z))},null,8,["choices","value"]),r.value?(p(),v("small",nv,m(r.value),1)):j("",!0)]),D.value&&$.value!=="single"&&!o.value?(p(),v("div",sv,[P[15]||(P[15]=i("span",{class:"f-label"},"Direct control on the tile",-1)),we(it,{choices:x.value,value:E.value,onPick:P[6]||(P[6]=z=>y(Bt)(e.tile,"controls",z))},null,8,["choices","value"]),i("small",{class:le({warn:S.value.warn})},m(S.value.text),3)])):j("",!0),n.value!=="screen"&&!o.value?(p(),v("div",ov,[P[16]||(P[16]=i("span",{class:"f-label"},"On tap",-1)),we(it,{choices:J.value,value:L.value,onPick:P[7]||(P[7]=z=>y(Bt)(e.tile,"tap",z))},null,8,["choices","value"]),C.value?(p(),v("small",{key:0,class:le({warn:C.value.warn})},m(C.value.text),3)):j("",!0)])):j("",!0),n.value!=="screen"&&!o.value&&L.value==="action"?(p(),Je(Ph,{key:5,tile:e.tile},null,8,["tile"])):j("",!0),I.value&&!o.value?(p(),v("div",iv,[P[17]||(P[17]=i("span",{class:"f-label"},"Small slider on the tile",-1)),we(it,{choices:[["none","No"],["slider","Yes, control directly"]],value:V.value,onPick:P[8]||(P[8]=z=>y(Bt)(e.tile,"inline",z))},null,8,["value"]),A.value?(p(),v("small",lv,"Home Assistant has nothing a slider can change for this entity. Choose No.")):j("",!0)])):j("",!0),n.value==="sensor"&&!o.value?(p(),v("div",av,[P[18]||(P[18]=i("span",{class:"f-label"},"History",-1)),we(it,{choices:[[1,"1 hour"],[6,"6 hours"],[24,"24 hours"]],value:b.value,onPick:P[9]||(P[9]=z=>y(Bt)(e.tile,"history_hours",Number(z)))},null,8,["value"])])):j("",!0),i("div",rv,[P[19]||(P[19]=i("span",{class:"f-label"},"Pastel background",-1)),i("div",uv,[(p(!0),v(B,null,ie(T.value,([z,oe])=>(p(),v("button",{key:z,type:"button","aria-label":`Background: ${oe.label}`,"aria-pressed":(e.tile.options?.background||"auto")===z?"true":"false",onClick:fe=>y(Bt)(e.tile,"background",z)},[i("i",{class:le(oe.color?"":z==="none"?"none":"auto"),style:Ae(oe.color?{background:oe.color}:void 0)},null,6),X(m(oe.label),1)],8,cv))),128))])])]),i("div",dv,[i("button",{type:"button",class:"btn danger",onClick:P[10]||(P[10]=z=>y(Ia)(e.tile))},"Remove"),P[20]||(P[20]=i("span",{class:"spacer"},null,-1)),n.value!=="screen"?(p(),v("button",{key:0,type:"button",class:"btn quiet",onClick:G},"Read current data")):j("",!0)])],64))}}),pv={class:"dr-head"},hv={class:"av mdi",style:{background:"var(--seg)",color:"var(--ink-2)"}},vv={class:"tx"},mv={class:"dr-body"},yv={class:"f"},gv=["value"],bv={class:"f"},wv={class:"f-label",id:"topbar-caption"},_v={style:{"text-transform":"none","letter-spacing":"0","font-weight":"500"}},kv={class:"items",id:"topbar-chips",role:"list","aria-labelledby":"topbar-caption"},$v=["data-index","aria-label","onPointerdown","onClick","onKeydown"],xv={class:"tx"},Sv=["aria-label","onClick"],Cv=["disabled","title"],Tv={class:"live",id:"topbar-live"},Av={class:"f"},Ev={class:"f"},Pv={key:1,class:"f"},Ov={key:2},Iv={key:0,class:"dr-foot"},Mv=["disabled"],Lv=["disabled"],Dv=Te({__name:"TopbarInspector",props:{index:{}},setup(e){const t=e,n=k(()=>Ht()),s=k(()=>n.value[t.index]),o=k(()=>(d.fontsVersion,d.now,d.topbarPreviews,ya(n.value,Ea.value,d.layout?.title||"Home",Vt))),l=k(()=>o.value.dropped),a=k(()=>d.inventory.header?.min_firmware||"0.2.32"),r=k(()=>{const[G,M,P]=a.value.split(".").map(Number);return Kt(G,M,P)}),c=k(()=>r.value?l.value.size?"Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name.":"Drag to reorder; tap an item to configure it.":`Sensors, the date, and the analog clock appear from firmware ${a.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`),f=(G,M)=>{const P=Vt(G);return P.shown?l.value.has(M)?"Doesn't fit next to the name":P.analog?"Dial":P.text:"Hidden: not active right now"},u=G=>{const M=Vt(G);return M.analog||G.type!=="entity"?fs(ma[G.type])?.cp:M.icon},h=G=>d.topbarAdded?.key===Ke(G)&&Date.now()-d.topbarAdded.time<1200;function w(G){d.layout&&(d.layout.title=G,Xe())}function $(G){const M=[...n.value];M[t.index]={...M[t.index],...G},is(M)}const D=k(()=>s.value?Vt(s.value).shown?l.value.has(t.index)?"Doesn't currently fit next to the name":"How it looks on the screen":"Hidden right now: not active":""),E=k(()=>Pn().clock_24h!==!1),x=k(()=>({clock:li(E.value,new Date(d.now)),analog:"Small dial with the time",date:ai(new Date(d.now))})),S=de({index:-1,active:!1,moved:!1});let L=null,J=0,C=null,V=0;function I(G,M){G.button!==0||G.target.closest(".x")||(G.pointerType!=="touch"&&G.preventDefault(),S.value={index:M,active:!1,moved:!1},L={x:G.clientX,y:G.clientY},C=G.pointerId,clearTimeout(J),G.pointerType==="touch"&&(J=window.setTimeout(A,260)),document.addEventListener("pointermove",T),document.addEventListener("pointerup",ne),document.addEventListener("pointercancel",ne))}function A(){S.value.active=!0,document.addEventListener("touchmove",b,{passive:!1})}function b(G){S.value.active&&G.preventDefault()}function T(G){if(G.pointerId!==C||!L)return;if(!S.value.active){const z=Math.hypot(G.clientX-L.x,G.clientY-L.y);if(G.pointerType==="touch"){z>10&&(clearTimeout(J),L=null);return}if(z<6)return;A()}const M=[...document.querySelectorAll(".items .item[data-index]")];let P=S.value.index;if(M.forEach((z,oe)=>{const fe=z.getBoundingClientRect();oeS.value.index&&G.clientY>fe.top+fe.height/2&&(P=Math.max(P,oe))}),P!==S.value.index&&d.layout){const z=[...n.value];z.splice(P,0,...z.splice(S.value.index,1)),d.layout.header={items:z};const oe=t.index===S.value.index;S.value.index=P,S.value.moved=!0,oe&&Ot(P)}}function ne(G){G.pointerId===C&&(clearTimeout(J),document.removeEventListener("pointermove",T),document.removeEventListener("pointerup",ne),document.removeEventListener("pointercancel",ne),document.removeEventListener("touchmove",b),S.value.active&&(V=Date.now()+400,S.value.moved&&Xe()),S.value={index:-1,active:!1,moved:!1},L=null,C=null)}function Se(G){Date.now()(p(),v(B,null,[i("div",pv,[i("span",hv,m(s.value?y(ge)(u(s.value)||"F0150"):y(ge)("F0150")),1),i("span",vv,[i("b",null,m(s.value?s.value.type==="entity"?y(ds)(s.value.entity):y(jn)(s.value):"Top bar"),1),i("small",{class:le({mono:s.value?.type==="entity"})},m(s.value?s.value.type==="entity"?s.value.entity:"From the screen itself, also works without Home Assistant":`${n.value.length} of ${y(qt)()} items`),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:M[0]||(M[0]=(...P)=>y(Be)&&y(Be)(...P))},"✕")]),i("div",mv,[i("div",yv,[M[10]||(M[10]=i("label",{class:"f-label",for:"title"},"Left: name",-1)),i("input",{id:"title",value:y(d).layout?.title,maxlength:"60",placeholder:"For example: Living room",onInput:M[1]||(M[1]=P=>w(P.target.value))},null,40,gv)]),i("div",bv,[i("span",wv,[M[11]||(M[11]=X("Right: time, sensors, or a clock ",-1)),i("span",_v," · "+m(n.value.length)+" / "+m(y(qt)()),1)]),i("div",kv,[(p(!0),v(B,null,ie(n.value,(P,z)=>(p(),v("div",{key:y(Ke)(P)+z,class:le(["item",{selected:z===e.index,"is-hidden":!y(Vt)(P).shown,"is-overflow":l.value.has(z),"just-added":h(P),"dragging-chip":S.value.active&&S.value.index===z}]),role:"listitem",tabindex:"0","data-index":z,"aria-label":`${y(jn)(P)}, slot ${z+1}. Enter: configure, arrows: move`,onPointerdown:oe=>I(oe,z),onClick:oe=>Se(z),onKeydown:oe=>Oe(oe,z)},[M[12]||(M[12]=i("span",{class:"grip","aria-hidden":"true"},"⋮⋮",-1)),i("span",{class:"av mdi",style:Ae(y(Vt)(P).color?{color:y(Vt)(P).color}:void 0)},m(u(P)?y(ge)(u(P)):""),5),i("span",xv,[i("b",null,m(y(jn)(P)),1),i("small",null,m(f(P,z)),1)]),i("button",{type:"button",class:"x","aria-label":`Remove ${y(jn)(P)} from the top bar`,onClick:ut(oe=>y(sl)(z),["stop"])},"✕",8,Sv)],42,$v))),128)),i("button",{type:"button",class:"ghost-btn",id:"topbar-add",disabled:n.value.length>=y(qt)(),title:n.value.length>=y(qt)()?`Maximum ${y(qt)()} items`:"Add the time, date, analog clock, or an entity",onClick:M[2]||(M[2]=(...P)=>y(el)&&y(el)(...P))},"+ Add time, date, or an entity",8,Cv)]),i("small",{id:"topbar-hint",class:le({warn:l.value.size>0&&r.value})},m(c.value),3)]),s.value?(p(),v(B,{key:0},[i("div",Tv,[i("small",null,m(D.value),1),we(Ua,{items:[s.value],single:""},null,8,["items"])]),s.value.type==="entity"?(p(),v(B,{key:0},[i("div",Av,[M[13]||(M[13]=i("span",{class:"f-label"},"What to show",-1)),we(it,{choices:(y(d).inventory.header?.contents||[]).map(P=>[P.key,P.label]),value:s.value.content,onPick:M[3]||(M[3]=P=>$({content:P}))},null,8,["choices","value"]),M[14]||(M[14]=i("small",null,"Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.",-1))]),we(ja,{selected:s.value.icon||"auto",automatic:y(d).topbarPreviews[y(Ke)(s.value)]?.auto_icon||y(en)(s.value.entity),"auto-label":"Automatic (like Home Assistant)","allow-none":"",onPick:M[4]||(M[4]=P=>$({icon:P}))},null,8,["selected","automatic"]),i("div",Ev,[M[15]||(M[15]=i("span",{class:"f-label"},"Show",-1)),we(it,{choices:(y(d).inventory.header?.shows||[]).map(P=>[P.key,P.label]),value:s.value.show,onPick:M[5]||(M[5]=P=>$({show:P}))},null,8,["choices","value"]),M[16]||(M[16]=i("small",null,"Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.",-1))])],64)):s.value.type!=="date"?(p(),v("div",Pv,[M[17]||(M[17]=i("span",{class:"f-label"},"Format",-1)),we(it,{choices:[["24","24 hour"],["12","12 hour"]],value:E.value?"24":"12",onPick:M[6]||(M[6]=P=>y(Bn)("clock_24h",P==="24",150))},null,8,["value"]),M[18]||(M[18]=i("small",null,"The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.",-1))])):(p(),v("small",Ov,m(x.value.date)+" today. The date follows the screen's own clock.",1))],64)):j("",!0)]),s.value?(p(),v("div",Iv,[i("button",{type:"button",class:"btn danger",onClick:M[7]||(M[7]=P=>y(sl)(e.index))},"Remove"),M[19]||(M[19]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",disabled:e.index===0,onClick:M[8]||(M[8]=P=>y(_o)(e.index,e.index-1)&&y(Ot)(e.index-1))},"↑ Up",8,Mv),i("button",{type:"button",class:"btn quiet",disabled:e.index>=n.value.length-1,onClick:M[9]||(M[9]=P=>y(_o)(e.index,e.index+1)&&y(Ot)(e.index+1))},"↓ Down",8,Lv)])):j("",!0)],64))}}),Fv={class:"dr-head"},Nv={class:"tx"},Rv={class:"dr-body"},Hv={class:"f"},Uv={class:"options"},jv=["disabled","onClick"],Bv={class:"mdi"},Vv={class:"tx"},Wv={key:0,class:"f"},Kv={class:"options"},qv=["disabled","onClick"],Yv={class:"mdi"},zv={class:"tx"},Jv={class:"f"},Gv={class:"options"},Xv=["disabled","onClick"],Zv={class:"mdi"},Qv={class:"tx"},em={key:0,class:"hint"},tm={key:1,class:"hint"},nm={class:"dr-foot"},sm=Te({__name:"TopbarAdd",setup(e){const t=de(""),n=k(()=>new Set(Ht().map(Ke))),s=k(()=>Pn().clock_24h!==!1),o=k(()=>({clock:li(s.value,new Date(d.now)),analog:"Small dial with the time",date:ai(new Date(d.now))})),l=k(()=>d.inventory.header?.suggestions?.[d.selected||""]||[]),a=k(()=>{const c=t.value.trim().toLocaleLowerCase();return d.inventory.entities.filter(f=>`${f.name} ${f.id} ${f.area||""} ${f.device||""}`.toLocaleLowerCase().includes(c))}),r=c=>({type:"entity",entity:c,content:"state",icon:"auto",show:"always"});return(c,f)=>(p(),v(B,null,[i("div",Fv,[f[4]||(f[4]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"18px"}},"+",-1)),i("span",Nv,[f[3]||(f[3]=i("b",null,"Add to the top bar",-1)),i("small",null,m(y(Ht)().length)+" of "+m(y(qt)())+" slots used",1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:f[0]||(f[0]=(...u)=>y(Be)&&y(Be)(...u))},"✕")]),i("div",Rv,[i("div",Hv,[f[5]||(f[5]=i("span",{class:"f-label"},"From the screen itself",-1)),i("div",Uv,[(p(!0),v(B,null,ie(y(d).inventory.header?.builtin||[],u=>(p(),v("button",{key:u.type,type:"button",class:"option",disabled:n.value.has(y(Ke)({type:u.type})),onClick:h=>y(ko)({type:u.type})},[i("span",Bv,m(y(ge)(y(fs)(y(ma)[u.type])?.cp||"F0150")),1),i("span",Vv,[i("strong",null,m(u.label),1),i("small",null,m(n.value.has(y(Ke)({type:u.type}))?"Already added":o.value[u.type]),1)])],8,jv))),128))])]),l.value.length?(p(),v("div",Wv,[f[6]||(f[6]=i("span",{class:"f-label"},"Suggestions from Home Assistant",-1)),i("div",Kv,[(p(!0),v(B,null,ie(l.value,u=>(p(),v("button",{key:y(Ke)(u.item),type:"button",class:"option",disabled:n.value.has(y(Ke)(u.item)),onClick:h=>y(ko)(u.item)},[i("span",Yv,m(y(ge)(u.icon||y(en)(u.item.entity))),1),i("span",zv,[i("strong",null,m(u.label),1),i("small",null,m(n.value.has(y(Ke)(u.item))?"Already added":[u.name,u.area].filter(Boolean).join(" · ")),1)])],8,qv))),128))])])):j("",!0),i("div",Jv,[f[7]||(f[7]=i("label",{class:"f-label",for:"topbar-search"},"An entity",-1)),ye(i("input",{id:"topbar-search","onUpdate:modelValue":f[1]||(f[1]=u=>t.value=u),type:"search",placeholder:"Search by name, room, or entity, e.g. temperature or door","aria-label":"Search for an entity for the top bar"},null,512),[[De,t.value]]),i("div",Gv,[(p(!0),v(B,null,ie(a.value.slice(0,40),u=>(p(),v("button",{key:u.id,type:"button",class:"option",disabled:n.value.has(y(Ke)(r(u.id))),onClick:h=>y(ko)(r(u.id))},[i("span",Zv,m(y(ge)(y(en)(u.id))),1),i("span",Qv,[i("strong",null,m(u.name),1),i("small",null,m([u.area,u.id].filter(Boolean).join(" · ")),1)])],8,Xv))),128)),a.value.length?a.value.length>40?(p(),v("p",tm,m(a.value.length)+" results. Keep typing to narrow it down.",1)):j("",!0):(p(),v("p",em,"No entities found."))])])]),i("div",nm,[f[8]||(f[8]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",onClick:f[2]||(f[2]=(...u)=>y(Be)&&y(Be)(...u))},"Cancel")])],64))}}),om={class:"dr-head"},im={class:"tx"},lm={class:"dr-body",id:"inspector-section"},am={id:"inspection-summary"},rm={key:0,class:"hint warn"},um={key:1,class:"hint"},cm={id:"inspection"},dm=Te({__name:"InspectPanel",props:{entity:{}},setup(e){const t=e,n=de(null),s=de("Reading…"),o=de("");async function l(){if(d.selected){n.value=null,o.value="",s.value="Fetching current HA status...";try{const r=await ht(`screens/${encodeURIComponent(d.selected)}/inspect`),c=t.entity?r.tiles.filter(f=>f.entity===t.entity):r.tiles;n.value=c,s.value=JSON.stringify(t.entity?c[0]:r,null,2)}catch(r){o.value=r.message,s.value="",ke(r.message)}}}const a=r=>{const c=d.layout?.tiles.find(f=>f.entity===r)?.options||{};return`Small slider: ${c.inline==="slider"?"yes":"no"} · Display: ${Ta[c.display||"standard"]||c.display} · Size: ${Mc[c.size]||"normal"} · Control: ${Ec({entity:r,options:c},d.inventory)} · Background: ${d.inventory.backgrounds?.[c.background||"auto"]?.label||"Default"}`};return wn(l),dn(()=>t.entity,l),(r,c)=>(p(),v(B,null,[i("div",om,[c[2]||(c[2]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"16px"}},"⌕",-1)),i("span",im,[c[1]||(c[1]=i("b",null,"Current data",-1)),i("small",{class:le({mono:e.entity})},m(e.entity||"Connection, status, and supported properties"),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:c[0]||(c[0]=(...f)=>y(Be)&&y(Be)(...f))},"✕")]),i("div",lm,[i("div",am,[o.value?(p(),v("p",rm,m(o.value),1)):n.value?j("",!0):(p(),v("p",um,"Fetching current HA status...")),(p(!0),v(B,null,ie(n.value||[],f=>(p(),v("article",{key:f.entity,class:"inspection-tile"},[i("strong",null,m(f.entity),1),i("span",null,"Status: "+m(f.word||f.state),1),i("small",null,m(a(f.entity)),1)]))),128))]),i("pre",cm,m(s.value),1)]),i("div",{class:"dr-foot"},[i("button",{type:"button",class:"btn quiet",onClick:l},"Read again"),c[3]||(c[3]=i("span",{class:"spacer"},null,-1))])],64))}}),fm=["aria-hidden"],pm=Te({__name:"Drawer",setup(e){const t=k(()=>!!(d.inspector&&(d.inspector.kind!=="tile"||vo.value)));return(n,s)=>(p(),v("aside",{class:le(["drawer",{open:t.value}]),id:"tile-sheet","aria-hidden":t.value?"false":"true",onClick:s[0]||(s[0]=ut(()=>{},["stop"]))},[y(d).inspector?(p(),v(B,{key:0},[y(d).inspector.kind==="tile"&&y(vo)?(p(),Je(fv,{key:0,tile:y(vo)},null,8,["tile"])):y(d).inspector.kind==="bar"?(p(),Je(Dv,{key:1,index:y(d).inspector.index},null,8,["index"])):y(d).inspector.kind==="bar-add"?(p(),Je(sm,{key:2})):y(d).inspector.kind==="inspect"?(p(),Je(dm,{key:3,entity:y(d).inspector.entity},null,8,["entity"])):j("",!0)],64)):j("",!0)],10,fm))}}),hm={class:"main-head"},vm={class:"eyebrow",id:"screen-name"},mm=["title"],ym={class:"head-right"},gm={class:"seg",role:"tablist"},bm=["aria-pressed"],wm=["aria-pressed"],_m={key:1,id:"dirty",class:"chip dirty"},km=["disabled"],$m={key:0,class:"spin small"},xm=["aria-expanded"],Sm={key:3,class:"menu",role:"menu"},Cm=["disabled","title"],Tm=["disabled","aria-expanded"],Am={key:0,class:"sub"},Em=["onClick"],Pm=["disabled","title"],Om={class:"body",id:"body"},Im=Te({__name:"ScreenView",setup(e){const t=k(()=>Tt.value),n=k(()=>t.value.online?`${t.value.delivery} · ${t.value.status}`:"Offline · changes are saved"),s=k(()=>t.value.update?.available&&t.value.online&&t.value.update?.profile&&t.value.update?.host),o=k(()=>d.inventory.screens.filter(E=>E.id!==t.value.id&&E.layout?.tiles?.length)),l=de(!1),a=de(null);function r(){d.menuOpen=!1,l.value=!1}function c(){r(),d.overrideProfile=t.value.update?.profile||null,d.overrideFriendly=t.value.name,Ee("#override")}function f(){r(),d.selectedTile=null,d.inspector={kind:"inspect"}}function u(E){r(),!(d.dirty&&!confirm("Replace the unsaved layout with a copy of the other screen's?"))&&qc(E)}function h(){r(),a.value?.click()}async function w(E){const x=E.target,S=x.files?.[0];x.value="",S&&(d.dirty&&!confirm("Replace the unsaved layout with the imported one?")||zc(await S.text()))}function $(E){if(E.key==="Escape")if(d.menuOpen)r();else{if(d.palette)return;d.inspector&&!E.target?.closest?.(".picker")&&Be()}else(E.metaKey||E.ctrlKey)&&E.key.toLowerCase()==="s"&&(E.preventDefault(),d.dirty&&Ro())}function D(E){d.menuOpen&&!E.target.closest(".head-right .menu, #more")&&r()}return wn(()=>{document.addEventListener("keydown",$),document.addEventListener("click",D)}),On(()=>{document.removeEventListener("keydown",$),document.removeEventListener("click",D)}),(E,x)=>(p(),v(B,null,[i("header",hm,[i("div",null,[i("span",vm,m(t.value.name),1),i("h1",null,m(y(d).layout?.title||"Home"),1)]),i("span",{id:"delivery",class:le(["chip",{off:!t.value.online}]),title:n.value},[x[9]||(x[9]=i("span",{class:"dot"},null,-1)),X(m(n.value),1)],10,mm),i("div",ym,[i("div",gm,[i("button",{type:"button",id:"tab-layout",role:"tab","aria-pressed":y(d).tab==="layout"?"true":"false",onClick:x[0]||(x[0]=S=>y(d).tab="layout")},"Layout",8,bm),i("button",{type:"button",id:"tab-settings",role:"tab","aria-pressed":y(d).tab==="settings"?"true":"false",onClick:x[1]||(x[1]=S=>{y(d).tab="settings",y(Be)()})},"Screen settings",8,wm)]),y(d).dirty?(p(),v("span",_m,"Unsaved changes")):(p(),v("span",{key:0,id:"dirty",class:le(["chip",{sent:y(d).saved}])},m(y(d).saved?"Saved · sent to screen":"All saved"),3)),y(d).dirty?(p(),v("button",{key:2,id:"save",type:"button",class:"btn primary",disabled:y(d).busy,title:"⌘S",onClick:x[2]||(x[2]=S=>y(Ro)())},[y(d).busy?(p(),v("span",$m)):j("",!0),X(m(y(d).busy?"Saving…":"Save & send"),1)],8,km)):j("",!0),i("button",{id:"more",type:"button",class:"icon-btn","aria-label":"More","aria-haspopup":"menu","aria-expanded":y(d).menuOpen?"true":"false",onClick:x[3]||(x[3]=ut(S=>{y(d).menuOpen=!y(d).menuOpen,l.value=!1},["stop"]))},"···",8,xm),y(d).menuOpen?(p(),v("div",Sm,[i("button",{type:"button",role:"menuitem",id:"identify",disabled:!y(mn)(t.value)||!t.value.online,title:y(mn)(t.value)?"":"Needs firmware 0.2.31 or newer",onClick:x[4]||(x[4]=S=>{r(),y(Ma)(t.value)})},[...x[10]||(x[10]=[X("Identify ",-1),i("small",null,"blink the screen",-1)])],8,Cm),i("button",{type:"button",role:"menuitem",id:"inspect",onClick:f},[...x[11]||(x[11]=[X("Read current data ",-1),i("small",null,"from Home Assistant",-1)])]),x[17]||(x[17]=i("div",{class:"sep"},null,-1)),i("button",{type:"button",role:"menuitem",id:"copy-layout",disabled:!o.value.length,"aria-expanded":l.value?"true":"false",onClick:x[5]||(x[5]=ut(S=>l.value=!l.value,["stop"]))},[x[12]||(x[12]=X("Copy layout from… ",-1)),i("small",null,m(o.value.length?`${o.value.length} screen${o.value.length===1?"":"s"}`:"no other screen"),1)],8,Tm),l.value?(p(),v("div",Am,[(p(!0),v(B,null,ie(o.value,S=>(p(),v("button",{key:S.id,type:"button",role:"menuitem",onClick:L=>u(S.id)},[X(m(S.name)+" ",1),i("small",null,m(S.layout.tiles.length)+" tiles",1)],8,Em))),128))])):j("",!0),i("button",{type:"button",role:"menuitem",id:"export-layout",onClick:x[6]||(x[6]=S=>{r(),y(Da)()})},[...x[13]||(x[13]=[X("Export layout ",-1),i("small",null,"JSON",-1)])]),i("button",{type:"button",role:"menuitem",id:"import-layout",onClick:h},[...x[14]||(x[14]=[X("Import layout… ",-1),i("small",null,"JSON",-1)])]),x[18]||(x[18]=i("div",{class:"sep"},null,-1)),i("button",{type:"button",role:"menuitem",id:"open-override",disabled:!t.value.update?.profile,title:t.value.update?.profile?"":"No ESPHome profile found for this screen",onClick:c},[...x[15]||(x[15]=[X("Override YAML ",-1),i("small",null,"advanced",-1)])],8,Pm),i("button",{type:"button",role:"menuitem",onClick:x[7]||(x[7]=S=>{r(),y(Ee)("#firmware")})},"Firmware & USB"),s.value?(p(),v("button",{key:1,type:"button",role:"menuitem",onClick:x[8]||(x[8]=S=>{r(),y(Uo)(t.value)})},[x[16]||(x[16]=X("Update firmware ",-1)),i("small",null,m(t.value.update?.target),1)])):j("",!0)])):j("",!0),i("input",{ref_key:"fileInput",ref:a,type:"file",accept:"application/json,.json",hidden:"",onChange:w},null,544)])]),i("div",Om,[y(d).tab==="layout"?(p(),Je(Up,{key:0})):(p(),Je(nh,{key:1})),we(pm)])],64))}}),Mm={key:0,id:"choose",class:"empty"},Lm={key:1,id:"empty",class:"empty"},Dm=Te({__name:"EmptyState",setup(e){return(t,n)=>y(d).inventory.screens.length?(p(),v("section",Mm,[...n[1]||(n[1]=[i("span",{class:"empty-icon"},"▦",-1),i("h2",null,"Choose a screen",-1),i("p",null,"Pick one of your screens on the left to change its tiles, top bar and settings.",-1)])])):(p(),v("section",Lm,[n[2]||(n[2]=i("span",{class:"empty-icon"},"▦",-1)),n[3]||(n[3]=i("h2",null,"Start with a screen",-1)),n[4]||(n[4]=i("p",null,"Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.",-1)),i("button",{id:"start",type:"button",class:"btn primary",onClick:n[0]||(n[0]=s=>y(Ee)("#new-screen"))},"Install new screen")]))}}),Fm={class:"panel",id:"settings-view"},Nm={class:"panel-head"},Rm={class:"card-grid"},Hm={class:"card"},Um={class:"tools"},jm={key:0,class:"card updates",id:"updates"},Bm={id:"updates-hint"},Vm=["aria-valuenow"],Wm={class:"progress-text"},Km={key:0,class:"log-lines"},qm={key:2,class:"whatsnew"},Ym={class:"check"},zm=["checked"],Jm={class:"card"},Gm={class:"tools"},Xm={class:"card",id:"claude"},Zm={class:"actions"},Qm=["disabled"],ey={id:"claude-path"},ty=Te({__name:"AppSettingsView",setup(e){const t=k(()=>d.inventory.updates),n=k(()=>d.inventory.screens.filter(h=>h.update?.available).length),s=k(()=>t.value?t.value.busy?`Updating to firmware ${t.value.target}…`:d.inventory.screens.length?n.value?`Firmware ${t.value.target} is available for ${n.value} screen${n.value===1?"":"s"}.`:`All screens have firmware ${t.value.target}.`:`No screen paired yet. New screens get firmware ${t.value.target}.`:""),o=k(()=>d.inventory.screens.find(h=>h.update?.state==="running"||d.updating.includes(h.id))),l=k(()=>o.value?$n(o.value):null),a=k(()=>(d.firmwareJob?.logs||[]).slice(-12).join(`
-`)),r=k(()=>{const h=t.value?.changelog,w=t.value?.target;return!h||!w?[]:h.filter($=>$.firmware===w).flatMap($=>$.lines).slice(0,10)}),c=k(()=>d.inventory.claude_skill),f=de(!1);async function u(){f.value=!0;try{await nd()}finally{f.value=!1}}return(h,w)=>(p(),v("div",Fm,[i("div",Nm,[w[6]||(w[6]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Settings"),i("h1",{id:"settings-title"},"Everything around your screens.")],-1)),i("button",{type:"button",class:"btn quiet",id:"close-settings",onClick:w[0]||(w[0]=$=>y(Ee)(""))},"← My screens")]),i("div",Rm,[i("section",Hm,[w[9]||(w[9]=i("h2",null,"Screens",-1)),w[10]||(w[10]=i("p",null,"Add a screen, or build and reinstall the firmware of one you already have.",-1)),i("div",Um,[i("button",{type:"button",class:"tool",onClick:w[1]||(w[1]=$=>y(Ee)("#new-screen"))},[...w[7]||(w[7]=[i("span",{class:"tool-icon"},"+",-1),i("span",{class:"tx"},[i("b",null,"New screen"),i("small",null,"Connect and install")],-1)])]),i("button",{type:"button",class:"tool",onClick:w[2]||(w[2]=$=>y(Ee)("#firmware"))},[...w[8]||(w[8]=[i("span",{class:"tool-icon"},"⇪",-1),i("span",{class:"tx"},[i("b",null,"Firmware & USB"),i("small",null,"Build and install via USB or Wi-Fi")],-1)])])])]),t.value?(p(),v("section",jm,[w[12]||(w[12]=i("h2",null,"Firmware updates",-1)),i("p",Bm,m(s.value),1),o.value&&l.value?(p(),v(B,{key:0},[i("div",{class:"progress",role:"progressbar","aria-valuenow":l.value.percent,"aria-valuemin":"0","aria-valuemax":"100"},[i("i",{style:Ae({width:l.value.percent+"%"})},null,4)],8,Vm),i("div",Wm,[i("span",null,m(o.value.name)+" · "+m(l.value.percent)+" %",1),i("span",null,m(l.value.text),1)]),a.value?(p(),v("pre",Km,m(a.value),1)):j("",!0)],64)):j("",!0),t.value.pending&&!t.value.busy&&t.value.pending>=2?(p(),v("button",{key:1,id:"update-all",type:"button",class:"btn primary",onClick:w[3]||(w[3]=(...$)=>y(il)&&y(il)(...$))},"Update all "+m(t.value.pending)+" screens",1)):j("",!0),r.value.length&&!y(Ho)()?(p(),v("details",qm,[i("summary",null,"What's new in firmware "+m(t.value.target),1),i("ul",null,[(p(!0),v(B,null,ie(r.value,$=>(p(),v("li",{key:$},m($),1))),128))])])):j("",!0),i("label",Ym,[i("input",{type:"checkbox",id:"auto-update",checked:!!t.value.auto,onChange:w[4]||(w[4]=$=>y(td)($.target.checked))},null,40,zm),w[11]||(w[11]=i("span",null,[X("Update automatically every night"),i("small",null,"Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.")],-1))])])):j("",!0),i("section",Jm,[w[14]||(w[14]=i("h2",null,"Alerts",-1)),w[15]||(w[15]=i("p",null,"A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.",-1)),i("div",Gm,[i("button",{type:"button",class:"tool",onClick:w[5]||(w[5]=$=>y(Ee)("#alerts"))},[...w[13]||(w[13]=[i("span",{class:"tool-icon"},"!",-1),i("span",{class:"tx"},[i("b",null,"Alerts"),i("small",null,"Full-screen notifications, from an automation")],-1)])])])]),i("section",Xm,[w[18]||(w[18]=i("h2",null,"Claude",-1)),w[19]||(w[19]=i("p",null,'Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.',-1)),i("div",Zm,[i("button",{type:"button",id:"claude-install",class:le(["btn",c.value?.installed&&c.value.current?"quiet":"primary"]),disabled:!c.value||f.value,onClick:u},m(!c.value||!c.value.installed?"Install for Claude Code":c.value.current?"Install again":"Update the skill"),11,Qm),i("span",{id:"claude-status",class:le(["chip",c.value&&c.value.installed?c.value.current?"good":"update":""]),role:"status"},m(c.value?c.value.installed?c.value.current?"● Installed":"Installed · a newer version is ready":"Not installed":"Loading…"),3)]),i("small",null,[w[16]||(w[16]=X("For Claude Code in Home Assistant. Writes one file to ",-1)),i("code",ey,m(c.value?.path||"/homeassistant/.claude/skills/esp-screens"),1),w[17]||(w[17]=X("; installing again replaces it.",-1))]),w[20]||(w[20]=i("div",{class:"actions"},[i("a",{class:"btn quiet",id:"claude-download",href:"api/claude-skill.zip",download:"esp-screens.zip"},"Download for claude.ai"),i("small",null,"Upload the zip in Claude under Customize → Skills.")],-1))])])]))}}),ny={class:"panel",id:"installer"},sy={class:"panel-head"},oy={class:"tx"},iy={id:"install-title"},ly={key:0},ay={class:"boards"},ry={class:"board"},uy={class:"board"},cy={class:"field"},dy={class:"node-line"},fy={id:"node-preview"},py={key:0,class:"field",id:"node-label"},hy={key:1,id:"wifi-fields",class:"wifi"},vy={class:"hint",id:"wifi-status"},my={key:0,class:"field",id:"wifi-ssid-label"},yy={key:1,class:"field",id:"wifi-password-label"},gy={class:"field"},by={key:0,value:"usb"},wy=["value"],_y={id:"target-hint"},ky={key:2,class:"hint",id:"install-note"},$y={class:"actions"},xy=["disabled"],Sy={id:"install-status",class:"status-line error",role:"status"},Cy={key:1,id:"install-progress",class:"card"},Ty={class:"progress-head"},Ay={key:0,class:"spin",id:"progress-spin"},Ey={id:"progress-title"},Py={id:"progress-detail"},Oy={key:0,id:"install-download",class:"card",style:{background:"var(--surface-2)"}},Iy=["href","download"],My={class:"steps",id:"download-steps"},Ly={key:1,id:"install-result",class:"field"},Dy={class:"key-box"},Fy={class:"steps",id:"install-steps"},Ny=["open"],Ry={id:"install-log",class:"log"},Hy={class:"actions"},Uy="https://web.esphome.io/?dashboard_install",jy="Download · flash from your own computer",By=Te({__name:"InstallerView",setup(e){const t=yn({board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),n=yn({view:"setup",file:null,friendly:"",board:"cyd",target:"",apiKey:null,nodeEdited:!1,jobState:null,picked:!1,action:null}),s=de(!1),o=de(null),l=de(null),a=de([]),r=de(""),c=de(""),f=de(!1),u=de(!1),h=de(null);let w=0;function $(pe){return pe.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^[^a-z]+/,"").slice(0,30).replace(/-+$/,"")||"screen"}function D(pe){const H=pe.replace(/^\/dev\/serial\/by-id\/usb-/,"").replace(/-if\d+(-port\d+)?$/,"").replace(/_/g," ");return`USB · ${H===pe?pe.replace(/^\/dev\//,""):H}`}dn(()=>t.friendly_name,()=>{n.nodeEdited||(t.name=$(t.friendly_name))});const E=k(()=>t.name||"…"),x=k(()=>o.value?.ports||[]),S=k(()=>o.value?.wifi),L=k(()=>S.value?.state==="new"||S.value?.state==="missing"),J=k(()=>S.value?.missing||[]),C=k(()=>S.value?.state==="new"?"Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically.":"Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines."),V=k(()=>S.value?.state==="ready"?"Wi-Fi comes from your ESPHome secrets.yaml.":S.value?.state==="invalid"?"secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.":""),I=k(()=>t.target==="usb"?"Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer.":t.target==="download"?"Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi.":t.target?x.value.length>1?"More than one board connected: choose this screen's port.":"Once over USB; after that, everything is wireless.":"The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."),A=k(()=>t.target==="download"?"Build & download":t.target?"Install":"Save profile"),b=k(()=>o.value?.job?.state==="running"&&!(n.file&&o.value.job.file===n.file)),T=k(()=>f.value||S.value?.state==="invalid"||t.target==="usb"||!!t.target&&(b.value||!o.value?.available));function ne(){const pe=t.target;(pe==="download"||pe===""?n.picked:x.value.includes(pe))||(t.target=x.value[0]||"usb")}async function Se(){let pe;try{pe=await ht("firmware")}catch(ot){r.value=ot.message;return}o.value=pe;const H=pe.job,ce=H&&n.file&&H.file===n.file;if(ce&&(n.jobState=H.state),n.view==="setup"){if(ce&&H.state==="running"){Oe(H,pe.logs||[]);return}ne(),r.value=b.value?`A build or installation is already running (${H.file}). Wait for it to finish.`:!pe.available&&t.target?"The ESPHome CLI is missing from this installation; only saving the profile is possible.":V.value}else n.view==="progress"&&ce&&(l.value=H,a.value=pe.logs||[],H.state!=="running"&&H.state!=="success"&&(u.value=!0))}function Oe(pe,H){n.view="progress",n.jobState=pe.state,n.action=pe.action,l.value=pe,a.value=H}const G=k(()=>l.value?.state==="running"),M=k(()=>n.view==="done"||l.value?.state==="success"),P=k(()=>n.action==="download"),z=k(()=>n.view==="done"?"Profile saved.":G.value?"One moment…":M.value?P.value?"Ready to download.":"Done.":"That didn't work."),oe=k(()=>n.view==="done"?`${n.file} is in the ESPHome folder`:G.value?l.value?.stage==="upload"?`Writing firmware to ${n.friendly}…`:"Building firmware…":M.value?P.value?`Firmware for ${n.friendly} is ready`:`Firmware is on ${n.friendly}`:P.value?"Build failed":"Install failed"),fe=k(()=>n.view==="done"?"Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:":G.value?l.value?.stage==="upload"?"Don't disconnect the USB cable yet.":`A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${P.value?"build":"installation"} keeps running and you'll find it again under New screen.`:M.value?P.value?"Put it on the screen from your own computer, then pair the screen with Home Assistant.":`The screen boots up and connects to your Wi-Fi.${n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""} Pair it with Home Assistant now:`:a.value.filter(pe=>/error/i.test(pe)).pop()||a.value.filter(pe=>/failed/i.test(pe)).pop()||"See the log below."),Z=k(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.file||"")}/download`,name:(n.file||"").replace(/\.yaml$/,"")+".factory.bin"})),ue=k(()=>[["Go to Home Assistant → Settings → Devices & services.",` This happens outside ESP Screens. Home Assistant discovers ${n.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],["Paste the API key"," above when Home Assistant asks for an encryption key."],["Allow HA actions:"," ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],["Choose your tiles."," Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."]]);async function Me(pe){const H=pe.target;if(n.nodeEdited||(t.name=$(t.friendly_name)),!!H.reportValidity()){f.value=!0,c.value="";try{const ce={board:t.board,friendly_name:t.friendly_name,name:t.name,target:t.target};L.value&&(J.value.includes("wifi_ssid")&&(ce.wifi_ssid=t.wifi_ssid),J.value.includes("wifi_password")&&(ce.wifi_password=t.wifi_password));const ot=await st("firmware/profiles","POST",ce);Object.assign(n,{file:ot.file,apiKey:ot.api_key,friendly:t.friendly_name.trim(),board:t.board,target:t.target}),t.wifi_password="",ot.job?Oe(ot.job,[]):n.view="done"}catch(ce){c.value=ce.message}finally{f.value=!1}}}async function Fe(){try{if(!P.value){const{ports:H}=await ht("firmware");!H.includes(n.target)&&H.length===1&&(n.target=H[0])}const pe=await st("firmware/jobs","POST",P.value?{file:n.file,action:"download"}:{file:n.file,action:"install",target:n.target});Oe(pe,[])}catch(pe){ke(pe.message)}}function nn(){Object.assign(n,{view:"setup",file:null,apiKey:null,nodeEdited:!1,jobState:null,target:"",picked:!1,action:null}),Object.assign(t,{board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),s.value=!1,l.value=null,a.value=[],c.value="",r.value="",u.value=!1,Se()}function ps(){n.view==="progress"&&n.jobState!=="running"&&(n.view="done"),Ee("")}return wn(()=>{Se(),w=window.setInterval(Se,3e3)}),On(()=>clearInterval(w)),(pe,H)=>(p(),v("div",ny,[i("div",sy,[i("div",oy,[H[13]||(H[13]=i("span",{class:"eyebrow"},"New screen",-1)),i("h1",iy,m(n.view==="setup"?"Connect and install.":z.value),1),n.view==="setup"?(p(),v("p",ly,"ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.")):j("",!0)]),i("button",{type:"button",class:"btn quiet",id:"close-install","aria-label":"Close",onClick:ps},"← Back")]),n.view==="setup"?(p(),v("form",{key:0,id:"install-form",class:"card",onSubmit:ut(Me,["prevent"])},[i("fieldset",null,[H[16]||(H[16]=i("legend",null,"Which screen do you have?",-1)),i("div",ay,[i("label",ry,[ye(i("input",{type:"radio",name:"board",value:"cyd","onUpdate:modelValue":H[0]||(H[0]=ce=>t.board=ce)},null,512),[[Yi,t.board]]),H[14]||(H[14]=i("span",null,[i("b",null,"CYD · 2.8 inch"),i("small",null,"ESP32-2432S028 · 320 × 240")],-1))]),i("label",uy,[ye(i("input",{type:"radio",name:"board",value:"guition","onUpdate:modelValue":H[1]||(H[1]=ce=>t.board=ce)},null,512),[[Yi,t.board]]),H[15]||(H[15]=i("span",null,[i("b",null,"Guition · 4 inch"),i("small",null,"ESP32-S3-4848S040 · 480 × 480 · GT911")],-1))])])]),i("div",cy,[H[18]||(H[18]=i("label",{class:"f-label",for:"friendly_name"},"Name",-1)),ye(i("input",{id:"friendly_name",name:"friendly_name","onUpdate:modelValue":H[2]||(H[2]=ce=>t.friendly_name=ce),required:"",maxlength:"60",placeholder:"Living room screen",autocomplete:"off"},null,512),[[De,t.friendly_name]]),i("small",dy,[H[17]||(H[17]=X("Device name ",-1)),i("code",fy,m(E.value),1),i("button",{type:"button",class:"btn link mini",id:"edit-node",onClick:H[3]||(H[3]=ce=>{n.nodeEdited=!0,s.value=!0})},"customize")]),H[19]||(H[19]=i("small",null,"Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.",-1))]),s.value?(p(),v("div",py,[H[20]||(H[20]=i("label",{class:"f-label",for:"node-name"},"Device name",-1)),ye(i("input",{id:"node-name",name:"name","onUpdate:modelValue":H[4]||(H[4]=ce=>t.name=ce),pattern:"[a-z][a-z0-9\\-]{0,29}",maxlength:"30",autocomplete:"off",onInput:H[5]||(H[5]=ce=>n.nodeEdited=!0)},null,544),[[De,t.name]]),H[21]||(H[21]=i("small",null,"Lowercase letters, digits, and dashes. A different name for each screen.",-1))])):j("",!0),L.value?(p(),v("fieldset",hy,[H[24]||(H[24]=i("legend",null,"Wi-Fi",-1)),i("p",vy,m(C.value),1),J.value.includes("wifi_ssid")?(p(),v("div",my,[H[22]||(H[22]=i("label",{class:"f-label",for:"wifi_ssid"},"Wi-Fi name (2.4 GHz)",-1)),ye(i("input",{id:"wifi_ssid",name:"wifi_ssid","onUpdate:modelValue":H[6]||(H[6]=ce=>t.wifi_ssid=ce),autocomplete:"off"},null,512),[[De,t.wifi_ssid]])])):j("",!0),J.value.includes("wifi_password")?(p(),v("div",yy,[H[23]||(H[23]=i("label",{class:"f-label",for:"wifi_password"},"Wi-Fi password",-1)),ye(i("input",{id:"wifi_password",name:"wifi_password",type:"password","onUpdate:modelValue":H[7]||(H[7]=ce=>t.wifi_password=ce),autocomplete:"new-password"},null,512),[[De,t.wifi_password]])])):j("",!0)])):j("",!0),i("div",gy,[H[26]||(H[26]=i("label",{class:"f-label",for:"install-target"},"Install via",-1)),ye(i("select",{id:"install-target",name:"target","onUpdate:modelValue":H[8]||(H[8]=ce=>t.target=ce),onChange:H[9]||(H[9]=ce=>{n.picked=!0,Se()})},[x.value.length?j("",!0):(p(),v("option",by,"USB · no board found on the Home Assistant machine yet")),(p(!0),v(B,null,ie(x.value,ce=>(p(),v("option",{key:ce,value:ce},m(D(ce)),9,wy))),128)),i("option",{value:"download"},m(jy)),H[25]||(H[25]=i("option",{value:""},"Later · save profile only",-1))],544),[[pn,t.target]]),i("small",_y,m(I.value),1)]),r.value?(p(),v("p",ky,m(r.value),1)):j("",!0),i("div",$y,[i("button",{type:"submit",class:"btn primary",id:"install-go",disabled:T.value},m(A.value),9,xy),i("span",Sy,m(c.value),1)])],32)):(p(),v("div",Cy,[i("div",Ty,[G.value?(p(),v("span",Ay)):(p(),v("span",{key:1,class:le(["outcome",M.value?"ok":"bad"]),id:"progress-mark"},m(M.value?"✓":"✕"),3)),i("strong",Ey,m(oe.value),1)]),i("p",Py,m(fe.value),1),M.value&&P.value&&n.view!=="done"?(p(),v("div",Oy,[i("a",{class:"btn primary",id:"download-firmware",href:Z.value.href,download:Z.value.name},"Download "+m(Z.value.name),9,Iy),i("ol",My,[H[30]||(H[30]=i("li",null,[i("b",null,"Plug the screen into your computer"),X(" with a USB data cable.")],-1)),i("li",null,[i("b",null,[H[27]||(H[27]=X("Open ",-1)),i("a",{href:Uy,target:"_blank",rel:"noopener"},"ESPHome Web")]),H[28]||(H[28]=X(" in Chrome or Edge, click Connect and choose the screen's USB port.",-1))]),i("li",null,[H[29]||(H[29]=i("b",null,"Click Install",-1)),X(" and select "+m(Z.value.name)+". The screen restarts and joins your Wi-Fi."+m(n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""),1)])]),H[31]||(H[31]=i("small",null,"The file holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])):j("",!0),M.value?(p(),v("div",Ly,[i("div",Dy,[H[32]||(H[32]=i("span",null,"API key",-1)),i("code",{id:"api-key",ref_key:"keyBox",ref:h},m(n.apiKey||""),513),i("button",{type:"button",class:"btn quiet mini",id:"copy-key",onClick:H[10]||(H[10]=ce=>y(ct)(n.apiKey||"",h.value))},"Copy")]),i("ol",Fy,[(p(!0),v(B,null,ie(ue.value,([ce,ot],In)=>(p(),v("li",{key:In},[i("b",null,m(ce),1),X(m(ot),1),In===0?(p(),v("button",{key:0,type:"button",class:"btn quiet mini",onClick:H[11]||(H[11]=(...sn)=>y(Is)&&y(Is)(...sn))},"Open Devices & services")):j("",!0)]))),128))])])):j("",!0),n.view!=="done"?(p(),v("details",{key:2,id:"install-log-wrap",class:"log-wrap",open:u.value,onToggle:H[12]||(H[12]=ce=>u.value=ce.target.open)},[H[33]||(H[33]=i("summary",null,"ESPHome log",-1)),i("pre",Ry,m(a.value.join(`
-`)),1)],40,Ny)):j("",!0),i("div",Hy,[!G.value&&!M.value?(p(),v("button",{key:0,type:"button",class:"btn primary",id:"install-retry",onClick:Fe},"Retry")):j("",!0),i("button",{type:"button",class:"btn quiet",id:"install-close",onClick:nn},m(M.value?"Install another screen":"Start over"),1),i("button",{type:"button",class:le(["btn",M.value?"primary":"quiet"]),onClick:ps},m(M.value?"Done":"Close"),3)])]))]))}}),Vy={class:"panel",id:"firmware-dialog"},Wy={class:"panel-head"},Ky={class:"card"},qy={class:"card-grid"},Yy={class:"field"},zy=["value"],Jy={class:"field"},Gy={key:0,value:"usb"},Xy=["value"],Zy={key:0,class:"field",id:"firmware-host-label"},Qy=["placeholder"],eg={class:"actions"},tg=["disabled"],ng=["disabled"],sg=["disabled"],og={key:0,class:"spin"},ig={id:"firmware-status",class:"status-line",role:"status"},lg={key:0,id:"firmware-download",class:"card",style:{background:"var(--surface-2)"}},ag=["href","download"],rg={id:"firmware-log",class:"log"},ug="https://web.esphome.io/?dashboard_install",cg="Download · flash from your own computer",dg=Te({__name:"FirmwareView",setup(e){const t=de(null),n=de(""),s=de("ota"),o=de("");let l=0;async function a(E=!1){try{const x=await ht("firmware");t.value=x,(E||!n.value)&&(n.value=x.profiles[0]?.file||"");const S=x.ports||[];s.value.startsWith("/")&&!S.includes(s.value)&&(s.value=S[0]||"usb"),s.value==="usb"&&S.length&&(s.value=S[0])}catch(x){ke(x.message)}}const r=k(()=>t.value?.job?.state==="running"),c=k(()=>!t.value||r.value||!t.value.available||!t.value.profiles?.length),f=k(()=>c.value||s.value==="usb"),u=k(()=>t.value?.ports||[]),h=k(()=>t.value?t.value.available?t.value.job?`${t.value.job.file} · ${t.value.job.action} · ${t.value.job.state}`:"Choose the intended profile, then a USB port, an IP address, or Download.":"The ESPHome CLI is missing. Update the app to 0.2.0.":"Loading…"),w=k(()=>s.value==="download"&&!!n.value&&!!t.value?.downloads?.includes(n.value)),$=k(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.value)}/download`,name:n.value.replace(/\.yaml$/,"")+".factory.bin"}));async function D(E){try{await st("firmware/jobs","POST",{file:n.value,action:E==="validate"?"validate":E==="build"?"build":s.value==="download"?"download":"install",target:s.value==="ota"?o.value.trim():s.value==="download"?"":s.value}),await a()}catch(x){ke(x.message)}}return wn(()=>{a(!0),l=window.setInterval(()=>a(),3e3)}),On(()=>clearInterval(l)),(E,x)=>(p(),v("div",Vy,[i("div",Wy,[x[7]||(x[7]=$s('
Firmware & USB
Build and install screen firmware
The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), Wi-Fi / OTA with the hostname, or Download to put the firmware on the screen from your own computer. Check only validates, Build only compiles, Build & install puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.
',1)),i("button",{type:"button",class:"btn quiet",id:"close-firmware",onClick:x[0]||(x[0]=S=>y(Ee)(""))},"← Back")]),i("div",Ky,[i("div",qy,[i("div",Yy,[x[8]||(x[8]=i("label",{class:"f-label",for:"firmware-file"},"ESPHome profile",-1)),ye(i("select",{id:"firmware-file","onUpdate:modelValue":x[1]||(x[1]=S=>n.value=S)},[(p(!0),v(B,null,ie(t.value?.profiles||[],S=>(p(),v("option",{key:S.file,value:S.file},m(S.file),9,zy))),128))],512),[[pn,n.value]])]),i("div",Jy,[x[10]||(x[10]=i("label",{class:"f-label",for:"firmware-port"},"Install to",-1)),ye(i("select",{id:"firmware-port","onUpdate:modelValue":x[2]||(x[2]=S=>s.value=S)},[x[9]||(x[9]=i("option",{value:"ota"},"Wi-Fi / OTA",-1)),u.value.length?j("",!0):(p(),v("option",Gy,"USB · no board found on the Home Assistant machine yet")),(p(!0),v(B,null,ie(u.value,S=>(p(),v("option",{key:S,value:S},m(S),9,Xy))),128)),i("option",{value:"download"},m(cg))],512),[[pn,s.value]])]),s.value==="ota"?(p(),v("div",Zy,[x[11]||(x[11]=i("label",{class:"f-label",for:"firmware-host"},"IP address or hostname",-1)),ye(i("input",{id:"firmware-host","onUpdate:modelValue":x[3]||(x[3]=S=>o.value=S),placeholder:n.value?n.value.replace(/\.yaml$/,".local"):"screen-livingroom.local"},null,8,Qy),[[De,o.value]])])):j("",!0)]),i("div",eg,[i("button",{type:"button",class:"btn quiet",id:"firmware-validate",disabled:c.value,onClick:x[4]||(x[4]=S=>D("validate"))},"Check",8,tg),i("button",{type:"button",class:"btn quiet",id:"firmware-build",disabled:c.value,onClick:x[5]||(x[5]=S=>D("build"))},"Build only",8,ng),i("button",{type:"button",class:"btn primary",id:"firmware-install",disabled:f.value,onClick:x[6]||(x[6]=S=>D("install"))},m(s.value==="download"?"Build & download":"Build & install"),9,sg),r.value?(p(),v("span",og)):j("",!0)]),i("p",ig,m(h.value),1),w.value?(p(),v("div",lg,[i("a",{class:"btn primary",id:"firmware-download-link",href:$.value.href,download:$.value.name},"Download "+m($.value.name),9,ag),i("small",null,[x[12]||(x[12]=X("Put it on the screen with ",-1)),i("a",{href:ug,target:"_blank",rel:"noopener"},"ESPHome Web"),x[13]||(x[13]=X(" in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])])):j("",!0)]),i("pre",rg,m((t.value?.logs||[]).join(`
-`)),1)]))}}),fg={class:"panel wide alerts",id:"alerts-dialog"},pg={class:"panel-head"},hg={key:0,class:"hint"},vg={class:"alert-hero"},mg={class:"alert-mock",id:"alert-mock","aria-hidden":"true"},yg={class:"mdi alert-mock-icon",id:"alert-mock-icon"},gg={class:"alerts-nav","aria-label":"Sections"},bg=["data-jump","onClick"],wg={id:"alerts-try",class:"card"},_g={class:"field"},kg={value:"all"},$g=["value","disabled"],xg={class:"field"},Sg={class:"field"},Cg={class:"field"},Tg={id:"try-icons"},Ag=["value"],Eg={class:"field"},Pg=["value"],Og={class:"field"},Ig={class:"field"},Mg={class:"check field"},Lg={class:"actions field wide"},Dg=["disabled"],Fg={class:"status-line",id:"try-result",role:"status"},Ng={id:"alerts-screens",class:"card"},Rg={id:"alerts-min-firmware"},Hg={id:"alerts-screen-list",class:"options"},Ug={key:0,class:"hint"},jg={class:"alert-screen-head"},Bg={class:"copy-label"},Vg=["onClick"],Wg={id:"alerts-howto",class:"card"},Kg={class:"field"},qg=["value"],Yg={key:0,value:""},zg={class:"copy-line"},Jg={id:"alerts-example"},Gg={id:"alerts-all",class:"card"},Xg={id:"alerts-all-event"},Zg={id:"alerts-all-dismiss"},Qg={class:"copy-line"},eb={id:"alerts-all-example"},tb={id:"alerts-fields",class:"card"},nb={class:"table-scroll"},sb={id:"alerts-field-table"},ob={key:0},ib={id:"alerts-icons",class:"card"},lb={class:"chips",id:"alerts-suggested"},ab=["onClick"],rb={class:"mdi"},ub={class:"field"},cb={id:"alerts-icon-groups"},db={class:"subhead"},fb={class:"alert-icon-grid"},pb=["title","onClick"],hb={class:"mdi"},vb={key:0},mb={key:0,class:"hint"},yb={id:"alerts-colors",class:"card"},gb={class:"swatches",id:"alerts-swatches"},bb=["onClick"],wb={id:"alerts-events",class:"card"},_b={id:"alerts-event-name"},kb={class:"table-scroll"},$b={id:"alerts-ending-table"},xb={class:"copy-line"},Sb={id:"alerts-wait-example"},Cb={id:"alerts-tips",class:"card"},Tb={class:"alerts-list"},Ab=Te({__name:"AlertsView",setup(e){const t=k(()=>d.inventory.alerts),n=yn({screen:"all",title:"Someone is at the door",subtitle:"Door 3, back",icon:"doorbell",color:"orange",button_text:"Coming",timeout:30,flash:!0}),s=de(!1),o=de(""),l=k(()=>d.inventory.screens.filter(A=>mn(A)&&A.online));async function a(){s.value=!0,o.value="";try{const{screen:A,...b}=n,T=await Wc(A,b),ne=A==="all"?`${T.sent} screen${T.sent===1?"":"s"}`:d.inventory.screens.find(Se=>Se.id===A)?.name||"the screen";o.value=T.sent?`Sent to ${ne}.${T.skipped?` ${T.skipped} skipped (offline or old firmware).`:""}${T.unusable?.length?` Left empty: ${T.unusable.join(", ")}.`:""}`:"No screen could show it: check that a screen is online with firmware "+(t.value?.min_firmware||"0.2.31")+" or newer."}catch(A){o.value=A.message}finally{s.value=!1}}const r=k(()=>d.inventory.icons),c=de(""),f=de(""),u=A=>`"${String(A).replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`,h=A=>A.type==="string"?/^[a-z][a-z0-9-]*$/.test(A.example)?A.example:u(A.example):A.example===!0?"true":A.example===!1?"false":String(A.example),w=k(()=>d.inventory.screens.filter(A=>A.alert_action)),$=k(()=>c.value||w.value[0]?.alert_action||""),D=k(()=>{const A=(t.value?.fields||[]).map(b=>` ${b.name}: ${h(b)}`);return`action: ${$.value||"esphome._show_alert"}
-data:
-${A.join(`
-`)}`}),E=k(()=>["# Doorbell: show the alert and wait until someone presses the button.","actions:",` - action: ${$.value||"esphome._show_alert"}`," data:",' title: "Someone is at the door"',' subtitle: "Door 3, back"'," icon: doorbell"," color: orange",' button_text: "Coming"'," timeout: 0"," flash: true"," - wait_for_trigger:"," - trigger: event",` event_type: ${t.value?.event||"esphome.screen_alert"}`," event_data:"," action: ok",' timeout: "00:05:00"'," - if:"," - condition: template",' value_template: "{{ wait.trigger is not none }}"'," then:"," - action: notify.notify"," data:",' message: "Someone is coming to the door."'].join(`
-`)),x=k(()=>{const A=(t.value?.fields||[]).map(T=>` ${T.name}: ${h(T)}`),b=t.value?.camera;return b&&A.push(` # ${b.name}: ${b.example} # a Guition shows its picture on the card`),`event: ${t.value?.broadcast?.show||"esp_screens_show_alert"}
-event_data:
-${A.join(`
-`)}`}),S=k(()=>{if(!t.value||!r.value)return[];const A=f.value.trim().toLowerCase();return[...r.value.groups,{label:"Also available: control and weather icons",icons:t.value.extra_icons}].map(T=>({label:T.label,icons:T.icons.filter(ne=>!A||ne.name.includes(A)||(ne.label||"").toLowerCase().includes(A))})).filter(T=>T.icons.length)}),L=k(()=>t.value?.suggested_icons?.find(A=>A.name==="doorbell")),J=k(()=>t.value?.colors?.find(A=>A.name==="orange")),C={string:"text",int:"number",bool:"on / off"};function V(A){document.getElementById(A)?.scrollIntoView({behavior:"smooth",block:"start"})}const I=[["alerts-try","Try it"],["alerts-screens","Your screens"],["alerts-howto","In an automation"],["alerts-all","All screens"],["alerts-fields","The fields"],["alerts-icons","Icons"],["alerts-colors","Colors"],["alerts-behaviour","Behavior"],["alerts-events","Events"],["alerts-tips","Tips"]];return(A,b)=>(p(),v("div",fg,[i("div",pg,[b[15]||(b[15]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Alerts · cheatsheet"),i("h1",null,"A full-screen notification, from an automation."),i("p",null,[X("Every screen has the "),i("code",null,"show_alert"),X(" action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.")])],-1)),i("button",{type:"button",class:"btn quiet",id:"close-alerts",onClick:b[0]||(b[0]=T=>y(Ee)(""))},"← Back")]),t.value?(p(),v(B,{key:1},[i("div",vg,[i("div",mg,[i("div",{class:"alert-mock-card",style:Ae(J.value?{background:J.value.color}:void 0)},[i("span",yg,m(L.value?y(ge)(L.value.cp):""),1),b[16]||(b[16]=i("strong",{id:"alert-mock-title"},"Someone is at the door",-1)),b[17]||(b[17]=i("span",{id:"alert-mock-subtitle"},"Door 3, back",-1)),b[18]||(b[18]=i("span",{class:"fake",id:"alert-mock-button"},"Coming",-1))],4)]),i("nav",gg,[(p(),v(B,null,ie(I,([T,ne])=>i("button",{key:T,type:"button","data-jump":T,onClick:Se=>V(T)},m(ne),9,bg)),64))])]),i("section",wg,[b[28]||(b[28]=i("h2",null,"Try it",-1)),b[29]||(b[29]=i("p",null,"Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.",-1)),i("form",{class:"try-grid",onSubmit:ut(a,["prevent"])},[i("div",_g,[b[19]||(b[19]=i("label",{class:"f-label",for:"try-screen"},"Screen",-1)),ye(i("select",{id:"try-screen","onUpdate:modelValue":b[1]||(b[1]=T=>n.screen=T)},[i("option",kg,"All screens ("+m(l.value.length)+" ready)",1),(p(!0),v(B,null,ie(y(d).inventory.screens,T=>(p(),v("option",{key:T.id,value:T.id,disabled:!y(mn)(T)||!T.online},m(T.name)+m(y(mn)(T)&&T.online?"":" · not ready"),9,$g))),128))],512),[[pn,n.screen]])]),i("div",xg,[b[20]||(b[20]=i("label",{class:"f-label",for:"try-title"},"Title",-1)),ye(i("input",{id:"try-title","onUpdate:modelValue":b[2]||(b[2]=T=>n.title=T),maxlength:"64"},null,512),[[De,n.title]])]),i("div",Sg,[b[21]||(b[21]=i("label",{class:"f-label",for:"try-subtitle"},"Subtitle",-1)),ye(i("input",{id:"try-subtitle","onUpdate:modelValue":b[3]||(b[3]=T=>n.subtitle=T),maxlength:"240"},null,512),[[De,n.subtitle]])]),i("div",Cg,[b[22]||(b[22]=i("label",{class:"f-label",for:"try-icon"},"Icon",-1)),ye(i("input",{id:"try-icon","onUpdate:modelValue":b[4]||(b[4]=T=>n.icon=T),list:"try-icons",placeholder:"doorbell"},null,512),[[De,n.icon]]),i("datalist",Tg,[(p(!0),v(B,null,ie(t.value.suggested_icons,T=>(p(),v("option",{key:T.name,value:T.name},m(T.label||T.name),9,Ag))),128))])]),i("div",Eg,[b[24]||(b[24]=i("label",{class:"f-label",for:"try-color"},"Color",-1)),ye(i("select",{id:"try-color","onUpdate:modelValue":b[5]||(b[5]=T=>n.color=T)},[b[23]||(b[23]=i("option",{value:""},"White (default)",-1)),(p(!0),v(B,null,ie(t.value.colors,T=>(p(),v("option",{key:T.name,value:T.name},m(T.label),9,Pg))),128))],512),[[pn,n.color]])]),i("div",Og,[b[25]||(b[25]=i("label",{class:"f-label",for:"try-button"},"Button text",-1)),ye(i("input",{id:"try-button","onUpdate:modelValue":b[6]||(b[6]=T=>n.button_text=T),maxlength:"16"},null,512),[[De,n.button_text]])]),i("div",Ig,[b[26]||(b[26]=i("label",{class:"f-label",for:"try-timeout"},"Timeout (seconds, 0 waits for the button)",-1)),ye(i("input",{id:"try-timeout","onUpdate:modelValue":b[7]||(b[7]=T=>n.timeout=T),type:"number",min:"0",max:"86400"},null,512),[[De,n.timeout,void 0,{number:!0}]])]),i("label",Mg,[ye(i("input",{type:"checkbox",id:"try-flash","onUpdate:modelValue":b[8]||(b[8]=T=>n.flash=T)},null,512),[[lc,n.flash]]),b[27]||(b[27]=i("span",null,[X("Blink the backlight"),i("small",null,"Four times when the alert arrives.")],-1))]),i("div",Lg,[i("button",{type:"submit",class:"btn primary",id:"try-send",disabled:s.value||!l.value.length},m(s.value?"Sending…":"Send alert"),9,Dg),i("span",Fg,m(o.value),1)])],32)]),i("section",Ng,[b[32]||(b[32]=i("h2",null,"Your screens",-1)),i("p",null,[b[30]||(b[30]=X("Every screen has its own action, with the device name in it. From firmware ",-1)),i("code",Rg,m(t.value.min_firmware),1),b[31]||(b[31]=X("; update an older screen with its Update button under Screens.",-1))]),i("div",Hg,[y(d).inventory.screens.length?j("",!0):(p(),v("p",Ug,"No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.")),(p(!0),v(B,null,ie(y(d).inventory.screens,T=>(p(),v("div",{key:T.id,class:"alert-screen"},[i("div",jg,[i("strong",null,m(T.name),1),i("span",{class:le(["chip",y(Qt)(T.firmware,t.value.min_firmware)&&T.alert_action?"good":"update"])},m(y(Qt)(T.firmware,t.value.min_firmware)&&T.alert_action?`● firmware ${T.firmware}`:T.alert_action?`Update needed · firmware ${T.firmware||"unknown"}`:"Device name unknown · update the screen"),3)]),(p(!0),v(B,null,ie([["Show alert",T.alert_action],["Dismiss alert",T.dismiss_action]],([ne,Se])=>(p(),v("div",{key:ne,class:"copy-line"},[i("span",Bg,m(ne),1),i("code",null,m(Se||"esphome._show_alert"),1),Se?(p(),v("button",{key:0,type:"button",class:"btn quiet mini",onClick:Oe=>y(ct)(Se,void 0,"Action name")},"Copy",8,Vg)):j("",!0)]))),128))]))),128))])]),i("section",Wg,[b[34]||(b[34]=$s('
In an automation
Add action and search for ESPHome. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node device name".
Fill in the fields. Home Assistant asks for all seven; leave what you don't use empty ("", 0, off).
Prefer YAML? Choose Edit in YAML on the action and paste the example below.
',2)),i("div",Kg,[b[33]||(b[33]=i("label",{class:"f-label",for:"alerts-example-screen"},"Example for",-1)),ye(i("select",{id:"alerts-example-screen","onUpdate:modelValue":b[9]||(b[9]=T=>c.value=T),style:{"max-width":"360px"}},[(p(!0),v(B,null,ie(w.value,T=>(p(),v("option",{key:T.id,value:T.alert_action},m(T.name),9,qg))),128)),w.value.length?j("",!0):(p(),v("option",Yg,"a screen (not paired yet)"))],512),[[pn,c.value]])]),i("div",zg,[i("pre",Jg,m(D.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-example-copy",onClick:b[10]||(b[10]=T=>y(ct)(D.value,void 0,"YAML"))},"Copy YAML")])]),i("section",Gg,[b[42]||(b[42]=i("h2",null,"All screens at once",-1)),i("p",null,[b[35]||(b[35]=X("Fire the event ",-1)),i("code",Xg,m(t.value.broadcast?.show||"esp_screens_show_alert"),1),b[36]||(b[36]=X(" with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. ",-1)),i("code",Zg,m(t.value.broadcast?.dismiss||"esp_screens_dismiss_alert"),1),b[37]||(b[37]=X(" clears it everywhere. In an automation, add the ",-1)),b[38]||(b[38]=i("b",null,"Event",-1)),b[39]||(b[39]=X(" action or paste the example under ",-1)),b[40]||(b[40]=i("b",null,"Edit in YAML",-1)),b[41]||(b[41]=X(". Leave out the fields you don't use. ESP Screens has to be running for this.",-1))]),i("div",Qg,[i("pre",eb,m(x.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-all-copy",onClick:b[11]||(b[11]=T=>y(ct)(x.value,void 0,"YAML"))},"Copy YAML")])]),i("section",tb,[b[46]||(b[46]=i("h2",null,"The fields",-1)),b[47]||(b[47]=i("p",null,"Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).",-1)),i("div",nb,[i("table",sb,[b[45]||(b[45]=i("tr",null,[i("th",null,"Field"),i("th",null,"Type"),i("th",null,"What it does"),i("th",null,"Example"),i("th",null,"Limit")],-1)),(p(!0),v(B,null,ie(t.value.fields,T=>(p(),v("tr",{key:T.name},[i("td",null,[i("code",null,m(T.name),1),i("small",null,m(T.label),1)]),i("td",null,m(C[T.type]||T.type),1),i("td",null,m(T.help),1),i("td",null,[i("code",null,m(typeof T.example=="string"?T.example:String(T.example)),1)]),i("td",null,m(t.value.limits.cyd[T.name]?`CYD ${t.value.limits.cyd[T.name]} · Guition ${t.value.limits.guition[T.name]} bytes`:T.type==="int"?"0 to 86400 s":"—"),1)]))),128)),t.value.camera?(p(),v("tr",ob,[i("td",null,[i("code",null,m(t.value.camera.name),1),i("small",null,m(t.value.camera.label),1)]),b[43]||(b[43]=i("td",null,"entity",-1)),i("td",null,m(t.value.camera.help),1),i("td",null,[i("code",null,m(t.value.camera.example),1)]),b[44]||(b[44]=i("td",null,"Guition, firmware 0.2.57+",-1))])):j("",!0)])])]),i("section",ib,[b[49]||(b[49]=i("h2",null,"Icons",-1)),b[50]||(b[50]=i("p",null,[X("Fill in the name in "),i("code",null,"icon"),X(", for example "),i("code",null,"doorbell"),X(". "),i("code",null,"mdi:doorbell"),X(" and the hex codepoint ("),i("code",null,"F12E6"),X(") also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.")],-1)),b[51]||(b[51]=i("p",{class:"subhead"},"Handy for notifications",-1)),i("div",lb,[(p(!0),v(B,null,ie(t.value.suggested_icons,T=>(p(),v("button",{key:T.name,type:"button",class:"chip",onClick:ne=>y(ct)(T.name,void 0,"Icon name")},[i("span",rb,m(y(ge)(T.cp)),1),i("code",null,m(T.name),1)],8,ab))),128))]),i("div",ub,[b[48]||(b[48]=i("label",{class:"f-label",for:"alerts-icon-search"},"Search for an icon",-1)),ye(i("input",{id:"alerts-icon-search","onUpdate:modelValue":b[12]||(b[12]=T=>f.value=T),type:"search",placeholder:"Search by name or description…",autocomplete:"off"},null,512),[[De,f.value]])]),i("div",cb,[(p(!0),v(B,null,ie(S.value,T=>(p(),v("div",{key:T.label,class:"field"},[i("p",db,m(T.label)+" · "+m(T.icons.length),1),i("div",fb,[(p(!0),v(B,null,ie(T.icons,ne=>(p(),v("button",{key:ne.name,type:"button",class:"alert-icon",title:`Copy ${ne.name}`,onClick:Se=>y(ct)(ne.name,void 0,"Icon name")},[i("span",hb,m(y(ge)(ne.cp)),1),i("code",null,m(ne.name),1),ne.label?(p(),v("small",vb,m(ne.label),1)):j("",!0)],8,pb))),128))])]))),128)),S.value.length?j("",!0):(p(),v("p",mb,'No icon for "'+m(f.value)+'". Unknown names show the warning triangle ('+m(t.value.fallback_icon)+").",1))])]),i("section",yb,[b[53]||(b[53]=i("h2",null,"Colors",-1)),b[54]||(b[54]=i("p",null,[X("Fill in the name in "),i("code",null,"color"),X(". These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.")],-1)),i("div",gb,[i("button",{type:"button",class:"swatch",onClick:b[13]||(b[13]=T=>y(ct)("",void 0,"Empty color"))},[...b[52]||(b[52]=[i("i",{style:{background:"#ffffff"}},null,-1),i("span",null,[i("code",null,"empty"),i("small",null,"White (default)")],-1)])]),(p(!0),v(B,null,ie(t.value.colors,T=>(p(),v("button",{key:T.name,type:"button",class:"swatch",onClick:ne=>y(ct)(T.name,void 0,"Color name")},[i("i",{style:Ae({background:T.color})},null,4),i("span",null,[i("code",null,m(T.name),1),i("small",null,m(T.label)+" · "+m(T.color),1)])],8,bb))),128))])]),b[75]||(b[75]=$s('
Behavior
Wakes the screen. An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.
On until the button. As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.
Timeout.timeout: 0 waits for the button, however long that takes. With timeout: 60 the card disappears after a minute, but the button closes it sooner.
Blinking.flash: true blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.
Replacing. A new alert replaces the current one; there's no stack. The replaced alert reports itself as replaced.
Closing remotely.dismiss_alert clears the card, for example if the door is already open. Without an alert, that action does nothing.
Tapping beside the card does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.
',1)),i("section",wb,[b[66]||(b[66]=i("h2",null,"Events",-1)),i("p",null,[b[55]||(b[55]=X("Every end fires the event ",-1)),i("code",_b,m(t.value.event),1),b[56]||(b[56]=X(" with ",-1)),b[57]||(b[57]=i("code",null,"action",-1)),b[58]||(b[58]=X(", ",-1)),b[59]||(b[59]=i("code",null,"title",-1)),b[60]||(b[60]=X(", ",-1)),b[61]||(b[61]=i("code",null,"screen",-1)),b[62]||(b[62]=X(" (the device name) and the ",-1)),b[63]||(b[63]=i("code",null,"device_id",-1)),b[64]||(b[64]=X(" that Home Assistant adds. Visible under Settings → Tools → Events.",-1))]),i("div",kb,[i("table",$b,[b[65]||(b[65]=i("tr",null,[i("th",null,"action"),i("th",null,"When")],-1)),(p(!0),v(B,null,ie(t.value.endings,T=>(p(),v("tr",{key:T.action},[i("td",null,[i("code",null,m(T.action),1)]),i("td",null,m(T.label),1)]))),128))])]),b[67]||(b[67]=i("p",{class:"subhead"},"Waiting until someone presses the button",-1)),i("div",xb,[i("pre",Sb,m(E.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-wait-copy",onClick:b[14]||(b[14]=T=>y(ct)(E.value,void 0,"YAML"))},"Copy YAML")])]),i("section",Cb,[b[74]||(b[74]=i("h2",null,"Tips",-1)),i("ul",Tb,[b[72]||(b[72]=$s("
Use a color per kind of notification: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.
Keep the title short; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.
A custom button text turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.
For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with dismiss_alert.
",4)),i("li",null,[b[68]||(b[68]=X("Every screen? Fire ",-1)),i("code",null,m(t.value.broadcast?.show||"esp_screens_show_alert"),1),b[69]||(b[69]=X(" once (see All screens at once). The screens still work independently and each reports with its own ",-1)),b[70]||(b[70]=i("code",null,"screen",-1)),b[71]||(b[71]=X(".",-1))]),b[73]||(b[73]=i("li",null,[X("Rather "),i("b",null,"ask Claude"),X(" for the automation? Install the ESP Screens skill under Settings → Claude.")],-1))])])],64)):(p(),v("p",hg,"The cheatsheet is still loading; one moment."))]))}}),Eb={class:"panel",id:"override-dialog"},Pb={class:"panel-head"},Ob={class:"tx"},Ib={id:"override-title"},Mb={class:"notice"},Lb={class:"mdi"},Db={class:"file-row"},Fb={id:"override-file"},Nb={class:"yaml-editor",id:"yaml-editor-wrap"},Rb={class:"actions"},Hb={id:"override-count",class:"hint"},Ub={class:"actions"},jb=["disabled"],Bb=["disabled"],Vb=`# Hardware-specific changes for this screen.
-# This file is kept when the shared firmware package updates.
-# Do not add esphome:, api:, ota:, wifi: or packages: here.
-
-# Example: a CYD with the ST7789V display controller.
-# "!extend" changes the display the shared package already defines;
-# a bare id would add a second, incomplete display and fail the build.
-display:
- - id: !extend my_display
- model: ST7789V
-`,Wb=Te({__name:"OverrideView",setup(e){const t=k(()=>d.overrideProfile),n=de(""),s=de(""),o=de(!1),l=de("Loading…"),a=de(""),r=de(!1),c=de(null),f=de(null),u=k(()=>n.value.split(`
-`).length),h=k(()=>Array.from({length:u.value},(I,A)=>A+1).join(`
-`)),w=k(()=>`${n.value.length} characters · ${u.value} line${u.value===1?"":"s"}`);let $=!0;function D(I,A=""){l.value=I,a.value=A}async function E(){if(!t.value){D("No ESPHome profile was found for this screen.","error");return}D("Loading…");try{const I=await ht(`firmware/profiles/${encodeURIComponent(t.value)}/override`);s.value=I.override_file,o.value=!!I.attached,n.value=I.exists&&I.content!==`{}
-`?I.content:"",D(I.attached?"Changes here apply on the next build.":"Save once to attach this file to the profile.")}catch(I){D(I.message,"error")}}async function x(I=!1){if(!(!t.value||r.value)){r.value=!0,D("Checking and saving…");try{const A=await st(`firmware/profiles/${encodeURIComponent(t.value)}/override`,"PUT",{content:n.value});if(s.value=A.override_file,o.value=!0,!I){D("Saved. The override is kept during firmware updates.","ok"),ke("Override YAML saved.");return}D("Saved. ESPHome is checking the complete profile…"),await st("firmware/jobs","POST",{file:t.value,action:"validate"}),S()}catch(A){D(A.message,"error")}finally{r.value=!1}}}function S(){const I=Date.now(),A=t.value,b=async()=>{if(!(!$||t.value!==A))try{const T=await ht("firmware"),ne=T.job;if(ne&&ne.file===A&&ne.state==="running")D(`ESPHome is checking the profile… ${ne.stage||""}`.trim());else if(ne&&ne.file===A&&ne.state==="success"){D("The complete profile is valid. Safe to build and install.","ok");return}else if(ne&&ne.file===A&&ne.state==="failed"){const Se=(T.logs||[]).filter(Oe=>/error|failed/i.test(Oe)).pop();D(Se||"ESPHome rejected the complete profile. See Firmware & USB for the full log.","error");return}Date.now()-I<72e5&&setTimeout(b,1200)}catch(T){D(T.message,"error")}};b()}function L(){(!n.value.trim()||confirm("Replace the current text with the example?"))&&(n.value=Vb,D("Example loaded. Save it when you are ready."),c.value?.focus())}function J(){(!n.value.trim()||confirm("Clear the local override?"))&&(n.value="",D("The override will be empty after you save."),c.value?.focus())}function C(I){const A=c.value;if(I.key==="Tab"){I.preventDefault();const b=A.selectionStart,T=A.selectionEnd;A.setRangeText(" ",b,T,"end"),n.value=A.value}else(I.ctrlKey||I.metaKey)&&I.key.toLowerCase()==="s"&&(I.preventDefault(),x(!1))}function V(){f.value&&c.value&&(f.value.scrollTop=c.value.scrollTop)}return wn(E),On(()=>{$=!1}),(I,A)=>(p(),v("div",Eb,[i("div",Pb,[i("div",Ob,[A[4]||(A[4]=i("span",{class:"eyebrow"},"Advanced · local override",-1)),i("h1",Ib,"Override YAML"+m(y(d).overrideFriendly?` · ${y(d).overrideFriendly}`:""),1),A[5]||(A[5]=i("p",null,"This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.",-1))]),i("button",{type:"button",class:"btn quiet",id:"close-override",onClick:A[0]||(A[0]=b=>y(Ee)(""))},"← Back")]),i("div",Mb,[i("span",Lb,m(y(ge)("F0493")),1),A[6]||(A[6]=i("div",null,[i("strong",null,"What is safe to change?"),i("span",null,"Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.")],-1))]),i("div",Db,[i("code",Fb,m(s.value||t.value||""),1),i("span",{id:"override-state",class:le(["chip",{good:o.value}])},m(o.value?"Active":"Ready to attach"),3)]),i("div",Nb,[i("div",{ref_key:"gutter",ref:f,class:"yaml-gutter",id:"override-gutter","aria-hidden":"true"},m(h.value),513),ye(i("textarea",{ref_key:"editor",ref:c,id:"override-editor","onUpdate:modelValue":A[1]||(A[1]=b=>n.value=b),spellcheck:"false",autocapitalize:"off",autocomplete:"off",autocorrect:"off","aria-label":"Local override YAML",placeholder:`# Example
-display:
- - id: !extend my_display
- model: ST7789V`,onKeydown:C,onScroll:V},null,544),[[De,n.value]])]),i("div",Rb,[i("button",{type:"button",class:"btn quiet mini",id:"override-example",onClick:L},"Use example"),i("button",{type:"button",class:"btn quiet mini",id:"override-empty",onClick:J},"Clear"),i("span",Hb,m(w.value),1)]),i("p",{id:"override-status",class:le(["status-line",a.value]),role:"status"},m(l.value),3),i("div",Ub,[i("button",{type:"button",class:"btn quiet",id:"override-check",disabled:r.value||!t.value,onClick:A[2]||(A[2]=b=>x(!0))},"Save & check",8,jb),i("button",{type:"button",class:"btn primary",id:"override-save",disabled:r.value||!t.value,onClick:A[3]||(A[3]=b=>x(!1))},"Save override",8,Bb)])]))}}),Kb={class:"main"},qb=Te({__name:"App",setup(e){const t=k(()=>lt.value==="#settings"?ty:lt.value==="#new-screen"?By:lt.value==="#firmware"?dg:lt.value==="#alerts"?Ab:lt.value==="#override"?Wb:Tt.value&&d.layout?Im:Dm);function n(s){(s.metaKey||s.ctrlKey)&&s.key.toLowerCase()==="k"&&(s.preventDefault(),d.palette=!d.palette)}return wn(()=>document.addEventListener("keydown",n)),On(()=>document.removeEventListener("keydown",n)),(s,o)=>(p(),v("div",{class:le(["app",{dragging:y(d).drag.active}])},[we(Pd),i("main",Kb,[(p(),Je(Jr(t.value)))]),we(Id),we(Bd)],2))}});pc(qb).mount("#app");id();
diff --git a/screen_manager/app/static/assets/index-DUJYFAbE.css b/screen_manager/app/static/assets/index-DUJYFAbE.css
new file mode 100644
index 0000000..7263bed
--- /dev/null
+++ b/screen_manager/app/static/assets/index-DUJYFAbE.css
@@ -0,0 +1 @@
+:root{color-scheme:light dark;--ground: #f5f6f8;--surface: #ffffff;--surface-2: #f9fafb;--line: #e4e7ec;--line-strong: #d3d8e0;--ink: #16181d;--ink-2: #565d6b;--muted: #7a8290;--accent: #2f63f0;--accent-ink: #ffffff;--accent-soft: #edf2ff;--good: #1e9a5f;--good-soft: #e6f6ee;--warn: #b56d00;--warn-soft: #fff3df;--danger: #c6372e;--danger-soft: #fdecea;--seg: #eef0f3;--shadow: 0 12px 40px rgba(20, 24, 40, .12), 0 1px 2px rgba(20, 24, 40, .06);--code-bg: #f1f3f6;--ui-font: -apple-system, BlinkMacSystemFont, system-ui, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}@media(prefers-color-scheme:dark){:root{--ground: #0e1014;--surface: #161a21;--surface-2: #1b2029;--line: #262c37;--line-strong: #333b49;--ink: #e9ecf1;--ink-2: #b4bbc7;--muted: #8a93a3;--accent: #6d93ff;--accent-ink: #0b1633;--accent-soft: #1b2540;--good: #3fbf7f;--good-soft: #14302a;--warn: #e0a03a;--warn-soft: #302610;--danger: #f07167;--danger-soft: #3a1b19;--seg: #222834;--shadow: 0 16px 48px rgba(0, 0, 0, .55), 0 1px 2px rgba(0, 0, 0, .4);--code-bg: #12151b}}@font-face{font-family:Tile Icons;src:url(./tile-icons-BawP5y9D.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:400;src:url(./bar-roboto-400-H0q_-5Ia.woff) format("woff");font-display:block}@font-face{font-family:Bar Roboto;font-weight:500;src:url(./bar-roboto-500-Bn7ZL9yA.woff) format("woff");font-display:block}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:var(--ground);color:var(--ink);font:13px/1.45 var(--ui-font);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;border-radius:8px}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input,select,textarea{background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:7px 10px;width:100%;color:var(--ink)}input::placeholder,textarea::placeholder{color:var(--muted)}select{appearance:auto}h1,h2,h3,h4{margin:0;font-weight:600;letter-spacing:-.01em}h1{font-size:20px;letter-spacing:-.02em}h2{font-size:15px}h3,h4{font-size:13px}p{margin:0}small,.hint{display:block;color:var(--muted);font-size:11.5px;font-weight:400;line-height:1.4}.hint.warn,small.warn{color:var(--warn)}code{font-family:var(--mono);font-size:.92em;background:var(--code-bg);padding:1px 6px;border-radius:5px;overflow-wrap:anywhere}pre{margin:0;padding:12px 14px;background:var(--code-bg);border:1px solid var(--line);border-radius:10px;overflow:auto;font:12px/1.5 var(--mono);color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.mdi{font-family:Tile Icons;font-style:normal;font-weight:400;line-height:1}[hidden]{display:none!important}.app{display:grid;grid-template-columns:232px minmax(0,1fr);height:100%;min-height:100vh}.app.dragging{user-select:none;-webkit-user-select:none;cursor:grabbing}.main{display:flex;flex-direction:column;min-width:0;min-height:0}.side{background:var(--surface);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:14px 10px;gap:4px;min-height:0;overflow:auto;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:9px;padding:4px 8px 6px;font-weight:600;font-size:13px}.brand .mark{width:22px;height:22px;border-radius:6px;background:var(--ink);color:var(--surface);display:grid;place-items:center;font-size:14px;line-height:1}.conn{display:block;padding:0 8px 8px;font-size:11px;color:var(--muted)}.conn:before{content:"●";margin-right:6px;color:var(--warn)}.conn.online:before{color:var(--good)}.side .label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 8px 4px}.nav-item{display:flex;align-items:center;gap:9px;width:100%;padding:7px 8px;border-radius:8px;text-align:left;color:var(--ink-2)}.nav-item:hover{background:var(--surface-2);color:var(--ink)}.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)}.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)}.nav-item .plus{width:18px;text-align:center;font-size:14px}.nav-item.ghost{color:var(--muted)}.nav-item.ghost:hover{color:var(--accent);background:var(--accent-soft)}.pill{font-size:10.5px;font-weight:600;padding:2px 7px;border-radius:999px;background:var(--warn-soft);color:var(--warn);flex:none}.side .spacer{flex:1}.screen-item{display:grid;gap:4px}.screen-update{display:grid;gap:6px;padding:2px 8px 8px 24px}.screen-update.available{grid-template-columns:1fr auto;align-items:center}.screen-update.available:has(.screen-host){grid-template-columns:1fr}.screen-update small.failed{color:var(--danger)}.screen-host{display:flex;gap:6px;align-items:center}.screen-host input{padding:5px 8px;font-size:12px}.pending{margin:4px 0;padding:10px;border:1px dashed var(--line-strong);border-radius:10px;display:grid;gap:6px}.pending strong{font-size:12.5px}.pending-actions{display:flex;flex-wrap:wrap;gap:6px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border-radius:8px;font-weight:600;font-size:12.5px;white-space:nowrap}.btn.primary{background:var(--accent);color:var(--accent-ink)}.btn.primary:hover:not(:disabled){filter:brightness(1.06)}.btn.quiet{color:var(--ink-2);border:1px solid var(--line-strong);background:var(--surface)}.btn.quiet:hover:not(:disabled){background:var(--surface-2);color:var(--ink)}.btn.danger{color:var(--danger)}.btn.danger:hover:not(:disabled){background:var(--danger-soft)}.btn.mini{padding:4px 9px;font-size:11.5px}.btn.link{color:var(--accent);padding:0;font-weight:600}.icon-btn{width:30px;height:30px;display:grid;place-items:center;border-radius:8px;color:var(--ink-2);font-size:15px;flex:none}.icon-btn:hover{background:var(--seg);color:var(--ink)}.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line);white-space:nowrap}.chip .dot{width:6px;height:6px;border-radius:50%;background:var(--good);flex:none}.chip.off .dot{background:var(--line-strong)}.chip.dirty{color:var(--warn);background:var(--warn-soft);border-color:transparent}.chip.sent,.chip.good{color:var(--good);background:var(--good-soft);border-color:transparent}.chip.update{color:var(--warn);background:var(--warn-soft);border-color:transparent}.seg{display:inline-flex;padding:2px;background:var(--seg);border-radius:8px;gap:2px}.seg button{padding:4px 11px;border-radius:6px;font-size:12px;font-weight:500;color:var(--ink-2);white-space:nowrap}.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px #14182824}.seg.wrap{flex-wrap:wrap}.seg button:hover:not(:disabled):not([aria-pressed=true]){color:var(--ink)}.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--line-strong);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:none}.spin.small{width:11px;height:11px;border-width:1.5px}@keyframes spin{to{transform:rotate(360deg)}}.switch{width:34px;height:20px;border-radius:10px;background:var(--line-strong);position:relative;flex:none;padding:0}.switch:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0003;transition:left .12s}.switch[aria-checked=true]{background:var(--accent)}.switch[aria-checked=true]:after{left:16px}.switch.unknown:after{left:9px;opacity:.6}@media(prefers-reduced-motion:reduce){.switch:after,.drawer,.spin{transition:none;animation:none}}.main-head{display:flex;align-items:center;gap:12px;padding:0 18px;min-height:52px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:4}.main-head h1{font-size:15px}.main-head .eyebrow{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.head-right{margin-left:auto;display:flex;align-items:center;gap:8px;position:relative}.menu{position:absolute;top:38px;right:0;min-width:210px;background:var(--surface);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;display:grid;z-index:6}.menu button{text-align:left;padding:7px 10px;border-radius:7px;color:var(--ink);display:flex;justify-content:space-between;gap:12px;width:100%}.menu button small{color:var(--muted);display:inline}.menu button:hover:not(:disabled){background:var(--surface-2)}.body{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 300px;flex:1;min-height:0;overflow:hidden}.canvas{overflow:auto;padding:22px 22px 26px;min-width:0}.canvas-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;color:var(--muted);font-size:12px;flex-wrap:wrap}.canvas-head b{color:var(--ink);font-weight:600;font-size:13px}.canvas-head .warn{color:var(--warn)}.pages{display:flex;gap:18px;align-items:flex-start}.page{flex:0 0 292px;display:grid;gap:8px}.page-label{display:flex;justify-content:space-between;align-items:center;font-size:11.5px;color:var(--muted);padding:0 4px;min-height:22px}.page-label .btn.mini{color:var(--muted)}.page-label .btn.mini:hover{color:var(--danger);background:var(--danger-soft)}.page.ghost .device{display:grid;place-items:center;border:1.5px dashed var(--line-strong);background:transparent;color:var(--muted);font-weight:500;cursor:pointer}.page.ghost .device:hover,.page.ghost .device:focus-visible{color:var(--accent);border-color:var(--accent)}.page.ghost .device.drop{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}.page.ghost.disabled .device{opacity:.5;cursor:default}.device{background:#e7e7e7;border-radius:18px;padding:10px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px;color:#1b1b1b;aspect-ratio:1 / 1}.device.cyd{aspect-ratio:4 / 3}.bar-wrap{cursor:pointer;border-radius:8px;padding:0 2px}.bar-wrap:hover{background:#dedede}.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: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}.tile.bare{background:transparent;border-style:dashed;border-color:#c3c9d0}.tile.placeholder{opacity:.55;border:2px dashed var(--accent);background:var(--accent-soft);cursor:default}.tile .ic{font-size:18px;line-height:1;color:#1b1b1b;flex:none}.tile .lead{display:grid;gap:2px;min-width:0;flex:1;align-content:end}.tile.wide .lead{display:flex;align-items:center;gap:8px;align-content:initial}.tile .tx{display:grid;min-width:0;gap:1px}.tile .head{display:flex;align-items:center;gap:8px;min-width:0;flex:1}.tile .head.top{flex:none}.tile .nm{font-size:11.5px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .st{font-size:10.5px;color:#5a5f66;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.device.cyd .tile{padding:6px 7px}.device.cyd .tile .nm{font-size:10.5px}.device.cyd .tile .ic{font-size:15px}.device.cyd .tile .head{gap:6px}.device.cyd .tile:has(>.mini-slider){padding-block:4px;gap:3px}.device.cyd .tile .mini-slider{height:8px}.tile .mini-slider{height:10px;border-radius:5px;background:linear-gradient(to right,#ffbf38 60%,#fff1d3 60%);flex:none;width:100%}.tile.wide .mini-slider{width:70px}.tile .ctl{flex:none;display:flex;align-items:center;gap:4px;color:#1b1b1b}.tile .tog{width:30px;height:17px;border-radius:9px;background:#2196f3;position:relative}.tile .tog:after{content:"";position:absolute;top:2px;right:2px;width:13px;height:13px;border-radius:50%;background:#fff}.tile .tog.off{background:#c9ccd1}.tile .tog.off:after{right:auto;left:2px}.tile .ic.lit{color:#d9930f}.tile .big{font-size:19px;font-weight:600;letter-spacing:-.02em;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .big small{display:inline;font-size:10.5px;font-weight:500;color:#5a5f66;margin-left:2px}.device.cyd .tile .big{font-size:16px}.tile .range{width:56px;height:14px;border-radius:7px;background:linear-gradient(to right,#2196f3 35%,#d3e8fb 35%)}.tile .key{height:20px;min-width:22px;padding:0 5px;border-radius:6px;background:#eceef1;display:grid;place-items:center;font-size:12px}.tile .stp{display:flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600}.tile .stp .mdi{font-size:12px}.tile .run{height:20px;padding:0 9px;border-radius:10px;background:#eceef1;font-size:11px;font-weight:600;line-height:20px}.tile .remove{position:absolute;top:5px;right:5px;width:22px;height:22px;border-radius:50%;background:#ffffffd9;color:#5a5f66;border:1px solid #dcdcdc;font-size:10px;line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .12s}.tile:hover .remove,.tile:focus-within .remove{opacity:1}.tile .remove:hover{color:var(--danger);border-color:#e7b4b0;background:#fff}@media(hover:none){.tile .remove{opacity:1}}.tile .clockface{width:46px;height:46px;margin:auto}.tile.wide .clockface{margin:0;flex:none}.cell{min-height:0;border:1px dashed #cfd4da;border-radius:12px;background:transparent;color:#a3abb5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-weight:500;cursor:pointer;padding:6px}.cell span{font-size:18px;line-height:1}.cell small{font-size:10.5px;color:inherit}.cell:hover,.cell:focus-visible{border-color:#9eb6cf;color:#4b5563;background:#f6f7f9}.cell.insert-here{border:2px dashed var(--accent);color:var(--accent);background:var(--accent-soft)}.drag-ghost{position:fixed;left:0;top:0;pointer-events:none;z-index:50;opacity:.92;box-shadow:var(--shadow);border-radius:12px}.library{border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;min-height:0}.lib-head{padding:14px 14px 10px;display:grid;gap:10px;border-bottom:1px solid var(--line)}.lib-title{display:flex;align-items:baseline;justify-content:space-between;font-weight:600;gap:8px}.lib-title small{color:var(--muted);font-weight:400;font-size:11.5px}.filters{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}.filters::-webkit-scrollbar{display:none}.filters button{flex:none;display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.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:manipulation}.ent:hover:not(:disabled){background:var(--surface-2)}.ent:disabled{opacity:.55;cursor:default}.ent .av{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;font-size:17px;flex:none}.ent .tx{display:grid;min-width:0;flex:1}.ent .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .tx small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ent .add{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:var(--accent);background:var(--accent-soft);font-size:15px;font-weight:600;flex:none}.ent .add.done{color:var(--good);background:var(--good-soft);font-size:12px}.lib-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}.domain-icon{display:inline-grid;place-items:center;border-radius:50%;font-weight:500}.drawer{position:absolute;top:0;right:0;bottom:0;width:360px;max-width:100%;background:var(--surface);border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translate(102%);transition:transform .18s cubic-bezier(.2,.7,.2,1);display:flex;flex-direction:column;min-height:0;z-index:5}.drawer.open{transform:none}.dr-head{display:flex;align-items:center;gap:10px;padding:12px 12px 12px 16px;border-bottom:1px solid var(--line)}.dr-head .av{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:20px;flex:none}.dr-head .tx{flex:1;min-width:0;display:grid}.dr-head .tx b{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small{color:var(--muted);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dr-head .tx small.mono{font-family:var(--mono)}.dr-body{overflow:auto;padding:14px 16px 18px;display:grid;gap:16px;align-content:start;flex:1}.f{display:grid;gap:7px}.f>.f-label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.f input,.f select{font-size:13px}.row{display:flex;align-items:center;gap:10px;padding:7px 10px 7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left}.row .av{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:grid;place-items:center;font-size:18px;flex:none}.row .tx{flex:1;min-width:0;display:grid}.row .tx b{font-weight:500}.row .tx small{font-size:11px}.row .link{color:var(--accent);font-weight:600;font-size:12px;flex:none}.sw{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.sw button{display:grid;justify-items:center;gap:4px;padding:6px 4px 5px;border-radius:9px;border:1.5px solid transparent;font-size:10.5px;color:var(--ink-2)}.sw button i{width:30px;height:22px;border-radius:6px;border:1px solid rgba(0,0,0,.08);display:block}.sw button i.auto{background:linear-gradient(135deg,#fff 50%,#e5e7eb 50%)}.sw button i.none{background:linear-gradient(135deg,transparent 46%,#8b95a1 46%,#8b95a1 54%,transparent 54%),#e7e7e7;border-style:dashed}.sw button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-soft);color:var(--ink)}.picker{display:grid;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.picker input{background:var(--surface)}.icon-list{display:grid;gap:12px;max-height:300px;overflow:auto;padding-right:2px}.icon-list small{margin-bottom:6px}.icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:6px}.icon-choice{display:grid;place-items:center;height:40px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px}.icon-choice .mdi{font-size:22px}.icon-choice[aria-pressed=true]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}.icon-choice.icon-auto{display:flex;justify-content:flex-start;gap:10px;padding:0 12px;font-size:12.5px;font-weight:500;height:38px}.action-list{display:grid;gap:6px;max-height:300px;overflow:auto;padding-right:2px}.action-choice{display:grid;gap:2px;padding:8px 12px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:9px;font-size:12.5px;text-align:left}.action-choice strong{font-weight:600}.action-choice[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)}.action-number{display:flex;align-items:center;gap:8px}.action-number input{flex:1;min-width:0}.items{display:grid;gap:6px}.item{display:flex;align-items:center;gap:8px;padding:7px 8px;border:1px solid var(--line-strong);border-radius:9px;background:var(--surface);width:100%;text-align:left;cursor:pointer;touch-action:pan-y}.item:hover{border-color:var(--accent)}.item.selected{border-color:var(--accent);background:var(--accent-soft)}.item.is-hidden{border-style:dashed}.item.is-hidden .av,.item.is-hidden b{opacity:.55}.item.is-overflow{border-style:dashed;border-color:var(--warn)}.item.is-overflow small{color:var(--warn)}.item.dragging-chip{border:2px dashed var(--accent);background:var(--accent-soft);cursor:grabbing}.item.just-added{animation:chip-added 1.2s ease-out}@keyframes chip-added{0%{background:var(--accent-soft)}to{background:var(--surface)}}.item .grip{color:var(--line-strong);font-size:13px;cursor:grab;letter-spacing:-2px;flex:none}.item .av{width:26px;height:26px;border-radius:7px;background:var(--surface-2);display:grid;place-items:center;font-size:15px;flex:none}.item .tx{flex:1;min-width:0;display:grid}.item .tx b{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .tx small{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.item .x{color:var(--muted);width:22px;height:22px;display:grid;place-items:center;border-radius:6px;flex:none;font-size:11px}.item .x:hover{color:var(--danger);background:var(--danger-soft)}.ghost-btn{border:1.5px dashed var(--line-strong);border-radius:9px;padding:8px;color:var(--accent);font-weight:600;width:100%;background:transparent}.ghost-btn:hover:not(:disabled){border-color:var(--accent);background:var(--accent-soft)}.dr-foot{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--surface-2);flex-wrap:wrap}.dr-foot .spacer{flex:1}.live{display:grid;gap:6px;padding:10px;border:1px solid var(--line);border-radius:10px;background:#e7e7e7;color:#1b1b1b;justify-items:start}.live small{color:#5a5f66}.live svg{display:block;height:28px}.option{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);width:100%;text-align:left}.option:hover:not(:disabled){border-color:var(--accent)}.option .mdi{font-size:20px;width:26px;text-align:center;color:var(--ink-2);flex:none}.option .tx{display:grid;min-width:0}.option .tx strong{font-weight:500;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.option .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.options{display:grid;gap:6px}.inspection-tile{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;margin-bottom:6px}.inspection-tile strong{font-size:12.5px}.settings{padding:22px;overflow:auto;display:grid;gap:14px;align-content:start;grid-column:1 / -1}.settings .status{font-size:12px;color:var(--muted)}.set-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}.set-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:6px 14px 8px}.set-card h4{display:flex;align-items:center;gap:8px;padding:10px 0 8px}.set-card h4 .mdi{color:var(--muted);font-size:16px}.srow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.srow>.s-label{color:var(--ink-2)}.srow.inactive .s-label,.srow.inactive .s-control{opacity:.45}.settings.offline .set-grid{opacity:.6}.step{display:inline-flex;align-items:center;gap:2px}.step button{width:26px;height:26px;border-radius:6px;color:var(--ink-2);background:var(--seg);display:grid;place-items:center}.step button .mdi{font-size:15px}.step button:hover:not(:disabled){color:var(--ink);background:var(--line-strong)}.step output{min-width:56px;text-align:center;font-weight:500}.panel{padding:22px 26px 40px;overflow:auto;display:grid;gap:18px;align-content:start;max-width:960px;width:100%}.panel.wide{max-width:1120px}.panel-head{display:flex;align-items:flex-start;gap:12px}.panel-head .tx{display:grid;gap:2px;flex:1;min-width:0}.panel-head h1{font-size:20px}.panel-head p{color:var(--muted);font-size:13px}.panel p,.panel li{color:var(--ink-2);font-size:13px}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 18px;display:grid;gap:12px;align-content:start}.card h2{font-size:14px}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.field{display:grid;gap:6px}.field>.f-label,fieldset legend{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}fieldset{border:0;padding:0;margin:0;display:grid;gap:8px;min-width:0}fieldset:disabled{opacity:.6}.boards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.board{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line-strong);border-radius:10px;cursor:pointer}.board:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}.board input{width:auto;margin:0;accent-color:var(--accent)}.board b{display:block;font-weight:600}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line{font-size:12.5px;color:var(--ink-2)}.status-line.ok{color:var(--good)}.status-line.error{color:var(--danger)}.progress-head{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}.outcome{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700}.outcome.ok{background:var(--good)}.outcome.bad{background:var(--danger)}.steps{margin:0;padding-left:20px;display:grid;gap:8px}.steps li b{color:var(--ink)}.key-box{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);flex-wrap:wrap}.key-box code{flex:1;min-width:0;background:none;padding:0;font-size:12px}.log{max-height:320px}details.log-wrap summary{cursor:pointer;font-weight:500;color:var(--ink-2);font-size:12.5px}details.log-wrap pre{margin-top:8px}.check{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.check input{width:18px;height:18px;margin:1px 0 0;accent-color:var(--accent)}.check span{display:grid;gap:2px;font-weight:500}.node-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tools{display:flex;gap:10px;flex-wrap:wrap}.tool{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);text-align:left;min-width:220px}.tool:hover{border-color:var(--accent)}.tool .tool-icon{width:30px;height:30px;border-radius:8px;background:var(--seg);display:grid;place-items:center;font-size:15px;color:var(--ink-2);flex:none}.tool .tx{display:grid}.tool .tx b{font-weight:600}.yaml-editor{display:grid;grid-template-columns:44px minmax(0,1fr);border:1px solid var(--line-strong);border-radius:10px;overflow:hidden;background:var(--code-bg);min-height:280px;max-height:60vh}.yaml-gutter{padding:12px 8px;text-align:right;color:var(--muted);font:12px/1.5 var(--mono);white-space:pre;user-select:none;overflow:hidden;border-right:1px solid var(--line)}.yaml-editor textarea{border:0;border-radius:0;background:transparent;resize:none;font:12px/1.5 var(--mono);padding:12px;white-space:pre;overflow:auto;min-height:280px;tab-size:2}.file-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.file-row code{font-size:12px}.notice{display:flex;gap:12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)}.notice .mdi{font-size:20px;color:var(--ink-2)}.notice strong{display:block;font-weight:600;margin-bottom:2px}.alerts section{display:grid;gap:10px}.alerts h2{font-size:15px}.alerts-nav{display:flex;flex-wrap:wrap;gap:6px}.alerts-nav button{background:var(--accent-soft);color:var(--accent);padding:5px 11px;font-size:12px;border-radius:20px;font-weight:500}.alert-hero{display:grid;grid-template-columns:minmax(0,300px) 1fr;gap:20px;align-items:center}.alert-mock{background:#101820;border-radius:16px;padding:22px 18px;display:grid;place-items:center}.alert-mock-card{position:relative;width:100%;background:#ffe1c6;border:1px solid #0001;border-radius:14px;padding:12px 12px 48px 52px;color:#1b1b1b;display:grid;gap:2px}.alert-mock-icon{position:absolute;left:12px;top:12px;font-size:30px}.alert-mock-card strong{font-size:15px;line-height:1.2}.alert-mock-card>span{font-size:12px;color:#3a3a3c}.alert-mock-card .fake{position:absolute;right:10px;bottom:10px;padding:6px 16px;border-radius:999px;background:#1b1b1b;color:#fff;font-size:13px}.alert-screen{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:8px;background:var(--surface)}.alert-screen-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.copy-line{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap}.copy-line .copy-label{flex:none;width:96px;font-size:12px;color:var(--muted);padding-top:2px}.copy-line>code{flex:1;min-width:0;padding:4px 8px}.copy-line pre{flex:1;min-width:0}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:560px}th,td{text-align:left;vertical-align:top;padding:8px 12px 8px 0;border-bottom:1px solid var(--line)}th{font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}td code{white-space:nowrap}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .chip .mdi{font-size:16px}.chips .chip code{background:none;padding:0}.alert-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}.alert-icon{display:grid;justify-items:center;align-content:start;gap:4px;min-height:86px;padding:10px 6px 8px;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:10px;font-weight:500}.alert-icon:hover{border-color:var(--accent)}.alert-icon .mdi{font-size:26px}.alert-icon code{background:none;padding:0;font-size:11px;color:var(--accent);text-align:center;white-space:normal}.alert-icon small{font-size:11px;text-align:center}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}.swatch{display:flex;align-items:center;gap:10px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font-size:12px;text-align:left;font-weight:500}.swatch:hover{border-color:var(--accent)}.swatch i{width:32px;height:32px;border-radius:8px;border:1px solid #0001;flex:none;display:block}.swatch span{display:grid;gap:2px;min-width:0}.swatch code{background:none;padding:0}.alerts-list{padding-left:18px;margin:0;display:grid;gap:8px}.alerts-list b{color:var(--ink)}.subhead{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:6px}.empty{margin:auto;max-width:420px;text-align:center;display:grid;gap:10px;justify-items:center;padding:60px 20px}.empty-icon{width:48px;height:48px;border-radius:14px;background:var(--seg);color:var(--muted);display:grid;place-items:center;font-size:26px}.empty h2{font-size:18px}.empty p{color:var(--muted)}.toast{position:fixed;left:50%;bottom:20px;transform:translate(-50%);display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--ink);color:var(--surface);border-radius:10px;box-shadow:var(--shadow);font-size:13px;z-index:60;max-width:min(92vw,560px)}.toast button{color:#9fc4ff;font-weight:650}@media(max-width:960px){.app{grid-template-columns:1fr;min-height:100vh}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:10px;gap:4px 6px}.side .label,.side .spacer,.conn{display:none}.side #screens,.screen-item{display:contents}.screen-update{display:none}.side #pending{flex-basis:100%}.nav-item{width:auto}.more{display:contents}.main-head{flex-wrap:wrap;padding:10px 14px;gap:8px;position:static}.head-right{margin-left:0;width:100%}.body{grid-template-columns:1fr;overflow:visible}.canvas{padding:16px;overflow:visible}.pages{overflow-x:auto;padding-bottom:8px}.library{border-left:0;border-top:1px solid var(--line)}.lib-list{flex:none;max-height:360px}.drawer{position:fixed;top:0;width:100%}.panel{padding:16px}.alert-hero{grid-template-columns:1fr}}.progress{position:relative;height:6px;border-radius:3px;background:var(--seg);overflow:hidden}.progress i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:3px;transition:width .4s}.progress-text{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)}.progress-text span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}.whatsnew summary{cursor:pointer;font-size:11.5px;color:var(--accent);font-weight:500;list-style:none}.whatsnew summary::-webkit-details-marker{display:none}.whatsnew ul{margin:6px 0 0;padding-left:14px;display:grid;gap:4px;font-size:11px;color:var(--ink-2)}.whatsnew ul li{line-height:1.35}.log-lines{max-height:160px;font-size:11px}.palette-backdrop{position:fixed;inset:0;background:#0a0c1247;z-index:70;display:grid;align-items:start;justify-items:center;padding:12vh 16px 16px}.palette{width:min(640px,100%);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:70vh}.palette input{border:0;border-radius:0;border-bottom:1px solid var(--line);padding:14px 16px;font-size:15px;background:transparent}.palette-list{overflow:auto;padding:6px;display:grid;gap:2px}.palette-group{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 10px 4px}.palette-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;width:100%;text-align:left;color:var(--ink)}.palette-item .mdi,.palette-item .glyph{width:22px;text-align:center;color:var(--muted);font-size:16px;flex:none}.palette-item .tx{flex:1;min-width:0;display:grid}.palette-item .tx small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.palette-item .hint-key{font-size:10.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 5px}.palette-item.active,.palette-item:hover{background:var(--accent-soft)}.palette-empty{padding:18px;color:var(--muted);font-size:12.5px;text-align:center}.side .search-btn{display:flex;align-items:center;gap:8px;width:100%;margin:4px 0 6px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;text-align:left}.side .search-btn kbd{margin-left:auto;font:10.5px var(--ui-font);border:1px solid var(--line);border-radius:4px;padding:1px 5px;background:var(--surface)}.side .search-btn:hover{color:var(--ink);border-color:var(--line-strong)}.menu .sub{display:grid;padding:2px 0 4px 10px}.menu .sub button{font-size:12.5px;padding:6px 10px}.menu .sep{height:1px;background:var(--line);margin:4px 6px}.lib-row{display:flex;gap:6px;align-items:center}.lib-row select{padding:5px 8px;font-size:12px;flex:1;min-width:0}.lib-row .chip-toggle{flex:none;padding:5px 10px;border-radius:999px;font-size:11.5px;font-weight:500;color:var(--ink-2);background:var(--surface-2);border:1px solid var(--line)}.lib-row .chip-toggle[aria-pressed=true]{background:var(--ink);color:var(--surface);border-color:var(--ink)}.ent .av.on{background:#fff3d6!important;color:#b7791f!important}.ent .av.gone{background:var(--seg)!important;color:var(--muted)!important}.try-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.try-grid .field.wide{grid-column:1 / -1}.tile.full{grid-column:span 2;grid-row:span 3;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px}.tile.full .ic{font-size:34px}.tile.full .lead{flex:0 0 auto;align-content:center;justify-items:center}.tile.full .nm{font-size:14px}.tile.full .st{font-size:11px}.tile.full .ctl{margin-top:6px}.tile.full .mini-slider{width:60%}.tile .goto{font-size:10.5px;color:#5a5f66}.device.cyd .tile.full .ic{font-size:26px}
diff --git a/screen_manager/app/static/assets/index-DfYonu9c.js b/screen_manager/app/static/assets/index-DfYonu9c.js
new file mode 100644
index 0000000..ffd9adc
--- /dev/null
+++ b/screen_manager/app/static/assets/index-DfYonu9c.js
@@ -0,0 +1,28 @@
+(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const l of o)if(l.type==="childList")for(const a of l.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&s(a)}).observe(document,{childList:!0,subtree:!0});function n(o){const l={};return o.integrity&&(l.integrity=o.integrity),o.referrerPolicy&&(l.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?l.credentials="include":o.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(o){if(o.ep)return;o.ep=!0;const l=n(o);fetch(o.href,l)}})();function Yo(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const Te={},dn=[],At=()=>{},yl=()=>!1,js=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Bs=e=>e.startsWith("onUpdate:"),Ue=Object.assign,zo=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Ga=Object.prototype.hasOwnProperty,be=(e,t)=>Ga.call(e,t),oe=Array.isArray,Gt=e=>cs(e)==="[object Map]",Rt=e=>cs(e)==="[object Set]",bi=e=>cs(e)==="[object Date]",ue=e=>typeof e=="function",Ie=e=>typeof e=="string",Et=e=>typeof e=="symbol",Se=e=>e!==null&&typeof e=="object",gl=e=>(Se(e)||ue(e))&&ue(e.then)&&ue(e.catch),bl=Object.prototype.toString,cs=e=>bl.call(e),Xa=e=>cs(e).slice(8,-1),wl=e=>cs(e)==="[object Object]",Jo=e=>Ie(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,qn=Yo(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Vs=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Za=/-\w/g,ze=Vs(e=>e.replace(Za,t=>t.slice(1).toUpperCase())),Qa=/\B([A-Z])/g,on=Vs(e=>e.replace(Qa,"-$1").toLowerCase()),Ws=Vs(e=>e.charAt(0).toUpperCase()+e.slice(1)),lo=Vs(e=>e?`on${Ws(e)}`:""),Tt=(e,t)=>!Object.is(e,t),ks=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Ks=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let wi;const qs=()=>wi||(wi=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Pe(e){if(oe(e)){const t={};for(let n=0;nn.startsWith("/*")?"":n).split(er).forEach(n=>{if(n){const s=n.split(tr);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function ie(e){let t="";if(Ie(e))t=e;else if(oe(e))for(let n=0;nvt(n,t))}const $l=e=>!!(e&&e.__v_isRef===!0),v=e=>Ie(e)?e:e==null?"":oe(e)||Se(e)&&(e.toString===bl||!ue(e.toString))?$l(e)?v(e.value):JSON.stringify(e,xl,2):String(e),xl=(e,t)=>$l(t)?xl(e,t.value):Gt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,o],l)=>(n[ao(s,l)+" =>"]=o,n),{})}:Rt(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>ao(n))}:Et(t)?ao(t):Se(t)&&!oe(t)&&!wl(t)?String(t):t,ao=(e,t="")=>{var n;return Et(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};let Re;class rr{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Re&&(Re.active?(this.parent=Re,this.index=(Re.scopes||(Re.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes){const s=this.scopes.slice();for(t=0,n=s.length;t0&&--this._on===0){if(Re===this)Re=this.prevScope;else{let t=Re;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(zn){let t=zn;for(zn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Yn;){let t=Yn;for(Yn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function Al(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function El(e){let t,n=e.depsTail,s=n;for(;s;){const o=s.prevDep;s.version===-1?(s===n&&(n=o),Qo(s),cr(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=o}e.deps=t,e.depsTail=n}function Co(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Pl(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Pl(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Zn)||(e.globalVersion=Zn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Co(e))))return;e.flags|=2;const t=e.dep,n=Ae,s=pt;Ae=e,pt=!0;try{Al(e);const o=e.fn(e._value);(t.version===0||Tt(o,e._value))&&(e.flags|=128,e._value=o,t.version++)}catch(o){throw t.version++,o}finally{Ae=n,pt=s,El(e),e.flags&=-3}}function Qo(e,t=!1){const{dep:n,prevSub:s,nextSub:o}=e;if(s&&(s.nextSub=o,e.prevSub=void 0),o&&(o.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let l=n.computed.deps;l;l=l.nextDep)Qo(l,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function cr(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let pt=!0;const Ol=[];function Ht(){Ol.push(pt),pt=!1}function Ut(){const e=Ol.pop();pt=e===void 0?!0:e}function $i(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Ae;Ae=void 0;try{t()}finally{Ae=n}}}let Zn=0;class dr{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class ei{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Ae||!pt||Ae===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Ae)n=this.activeLink=new dr(Ae,this),Ae.deps?(n.prevDep=Ae.depsTail,Ae.depsTail.nextDep=n,Ae.depsTail=n):Ae.deps=Ae.depsTail=n,Il(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=Ae.depsTail,n.nextDep=void 0,Ae.depsTail.nextDep=n,Ae.depsTail=n,Ae.deps===n&&(Ae.deps=s)}return n}trigger(t){this.version++,Zn++,this.notify(t)}notify(t){Xo();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Zo()}}}function Il(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)Il(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const To=new WeakMap,fn=Symbol(""),Ao=Symbol(""),Qn=Symbol("");function je(e,t,n){if(pt&&Ae){let s=To.get(e);s||To.set(e,s=new Map);let o=s.get(n);o||(s.set(n,o=new ei),o.map=s,o.key=n),o.track()}}function Dt(e,t,n,s,o,l){const a=To.get(e);if(!a){Zn++;return}const r=d=>{d&&d.trigger()};if(Xo(),t==="clear")a.forEach(r);else{const d=oe(e),f=d&&Jo(n);if(d&&n==="length"){const c=Number(s);a.forEach((y,b)=>{(b==="length"||b===Qn||!Et(b)&&b>=c)&&r(y)})}else switch((n!==void 0||a.has(void 0))&&r(a.get(n)),f&&r(a.get(Qn)),t){case"add":d?f&&r(a.get("length")):(r(a.get(fn)),Gt(e)&&r(a.get(Ao)));break;case"delete":d||(r(a.get(fn)),Gt(e)&&r(a.get(Ao)));break;case"set":Gt(e)&&r(a.get(fn));break}}Zo()}function kn(e){const t=ve(e);return t===e||(je(t,"iterate",Qn),ut(e))?t:Pt(e)?Xt(e)?t.map(n=>Qt(ct(n))):t.map(Qt):t.map(ct)}function Ys(e){return je(e=ve(e),"iterate",Qn),e}function St(e,t){return Pt(e)?Qt(Xt(e)?ct(t):t):ct(t)}const fr={__proto__:null,[Symbol.iterator](){return uo(this,Symbol.iterator,e=>St(this,e))},concat(...e){return kn(this).concat(...e.map(t=>oe(t)?kn(t):t))},entries(){return uo(this,"entries",e=>(e[1]=St(this,e[1]),e))},every(e,t){return Ot(this,"every",e,t,void 0,arguments)},filter(e,t){return Ot(this,"filter",e,t,n=>n.map(s=>St(this,s)),arguments)},find(e,t){return Ot(this,"find",e,t,n=>St(this,n),arguments)},findIndex(e,t){return Ot(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Ot(this,"findLast",e,t,n=>St(this,n),arguments)},findLastIndex(e,t){return Ot(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Ot(this,"forEach",e,t,void 0,arguments)},includes(...e){return co(this,"includes",e)},indexOf(...e){return co(this,"indexOf",e)},join(e){return kn(this).join(e)},lastIndexOf(...e){return co(this,"lastIndexOf",e)},map(e,t){return Ot(this,"map",e,t,void 0,arguments)},pop(){return Fn(this,"pop")},push(...e){return Fn(this,"push",e)},reduce(e,...t){return xi(this,"reduce",e,t)},reduceRight(e,...t){return xi(this,"reduceRight",e,t)},shift(){return Fn(this,"shift")},some(e,t){return Ot(this,"some",e,t,void 0,arguments)},splice(...e){return Fn(this,"splice",e)},toReversed(){return kn(this).toReversed()},toSorted(e){return kn(this).toSorted(e)},toSpliced(...e){return kn(this).toSpliced(...e)},unshift(...e){return Fn(this,"unshift",e)},values(){return uo(this,"values",e=>St(this,e))}};function uo(e,t,n){const s=Ys(e),o=s[t]();return s!==e&&!ut(e)&&(o._next=o.next,o.next=()=>{const l=o._next();return l.done||(l.value=n(l.value)),l}),o}const pr=Array.prototype;function Ot(e,t,n,s,o,l){const a=Ys(e),r=a!==e&&!ut(e),d=a[t];if(d!==pr[t]){const y=d.apply(e,l);return r?ct(y):y}let f=n;a!==e&&(r?f=function(y,b){return n.call(this,St(e,y),b,e)}:n.length>2&&(f=function(y,b){return n.call(this,y,b,e)}));const c=d.call(a,f,s);return r&&o?o(c):c}function xi(e,t,n,s){const o=Ys(e),l=o!==e&&!ut(e);let a=n,r=!1;o!==e&&(l?(r=s.length===0,a=function(f,c,y){return r&&(r=!1,f=St(e,f)),n.call(this,f,St(e,c),y,e)}):n.length>3&&(a=function(f,c,y){return n.call(this,f,c,y,e)}));const d=o[t](a,...s);return r?St(e,d):d}function co(e,t,n){const s=ve(e);je(s,"iterate",Qn);const o=s[t](...n);return(o===-1||o===!1)&&si(n[0])?(n[0]=ve(n[0]),s[t](...n)):o}function Fn(e,t,n=[]){Ht(),Xo();const s=ve(e)[t].apply(e,n);return Zo(),Ut(),s}const hr=Yo("__proto__,__v_isRef,__isVue"),Ml=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Et));function vr(e){Et(e)||(e=String(e));const t=ve(this);return je(t,"has",e),t.hasOwnProperty(e)}class Ll{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const o=this._isReadonly,l=this._isShallow;if(n==="__v_isReactive")return!o;if(n==="__v_isReadonly")return o;if(n==="__v_isShallow")return l;if(n==="__v_raw")return s===(o?l?Sr:Rl:l?Nl:Fl).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const a=oe(t);if(!o){let d;if(a&&(d=fr[n]))return d;if(n==="hasOwnProperty")return vr}const r=Reflect.get(t,n,We(t)?t:s);if((Et(n)?Ml.has(n):hr(n))||(o||je(t,"get",n),l))return r;if(We(r)){const d=a&&Jo(n)?r:r.value;return o&&Se(d)?Po(d):d}return Se(r)?o?Po(r):bn(r):r}}class Dl extends Ll{constructor(t=!1){super(!1,t)}set(t,n,s,o){let l=t[n];const a=oe(t)&&Jo(n);if(!this._isShallow){const f=Pt(l);if(!ut(s)&&!Pt(s)&&(l=ve(l),s=ve(s)),!a&&We(l)&&!We(s))return f||(l.value=s),!0}const r=a?Number(n)e,ys=e=>Reflect.getPrototypeOf(e);function wr(e,t,n){return function(...s){const o=this.__v_raw,l=ve(o),a=Gt(l),r=e==="entries"||e===Symbol.iterator&&a,d=e==="keys"&&a,f=o[e](...s),c=n?Eo:t?Qt:ct;return!t&&je(l,"iterate",d?Ao:fn),Ue(Object.create(f),{next(){const{value:y,done:b}=f.next();return b?{value:y,done:b}:{value:r?[c(y[0]),c(y[1])]:c(y),done:b}}})}}function gs(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function _r(e,t){const n={get(o){const l=this.__v_raw,a=ve(l),r=ve(o);e||(Tt(o,r)&&je(a,"get",o),je(a,"get",r));const{has:d}=ys(a),f=t?Eo:e?Qt:ct;if(d.call(a,o))return f(l.get(o));if(d.call(a,r))return f(l.get(r));l!==a&&l.get(o)},get size(){const o=this.__v_raw;return!e&&je(ve(o),"iterate",fn),o.size},has(o){const l=this.__v_raw,a=ve(l),r=ve(o);return e||(Tt(o,r)&&je(a,"has",o),je(a,"has",r)),o===r?l.has(o):l.has(o)||l.has(r)},forEach(o,l){const a=this,r=a.__v_raw,d=ve(r),f=t?Eo:e?Qt:ct;return!e&&je(d,"iterate",fn),r.forEach((c,y)=>o.call(l,f(c),f(y),a))}};return Ue(n,e?{add:gs("add"),set:gs("set"),delete:gs("delete"),clear:gs("clear")}:{add(o){const l=ve(this),a=ys(l),r=ve(o),d=!t&&!ut(o)&&!Pt(o)?r:o;return a.has.call(l,d)||Tt(o,d)&&a.has.call(l,o)||Tt(r,d)&&a.has.call(l,r)||(l.add(d),Dt(l,"add",d,d)),this},set(o,l){!t&&!ut(l)&&!Pt(l)&&(l=ve(l));const a=ve(this),{has:r,get:d}=ys(a);let f=r.call(a,o);f||(o=ve(o),f=r.call(a,o));const c=d.call(a,o);return a.set(o,l),f?Tt(l,c)&&Dt(a,"set",o,l):Dt(a,"add",o,l),this},delete(o){const l=ve(this),{has:a,get:r}=ys(l);let d=a.call(l,o);d||(o=ve(o),d=a.call(l,o)),r&&r.call(l,o);const f=l.delete(o);return d&&Dt(l,"delete",o,void 0),f},clear(){const o=ve(this),l=o.size!==0,a=o.clear();return l&&Dt(o,"clear",void 0,void 0),a}}),["keys","values","entries",Symbol.iterator].forEach(o=>{n[o]=wr(o,e,t)}),n}function ti(e,t){const n=_r(e,t);return(s,o,l)=>o==="__v_isReactive"?!e:o==="__v_isReadonly"?e:o==="__v_raw"?s:Reflect.get(be(n,o)&&o in s?n:s,o,l)}const kr={get:ti(!1,!1)},$r={get:ti(!1,!0)},xr={get:ti(!0,!1)};const Fl=new WeakMap,Nl=new WeakMap,Rl=new WeakMap,Sr=new WeakMap;function Cr(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function bn(e){return Pt(e)?e:ni(e,!1,yr,kr,Fl)}function Tr(e){return ni(e,!1,br,$r,Nl)}function Po(e){return ni(e,!0,gr,xr,Rl)}function ni(e,t,n,s,o){if(!Se(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const l=o.get(e);if(l)return l;const a=Cr(Xa(e));if(a===0)return e;const r=new Proxy(e,a===2?s:n);return o.set(e,r),r}function Xt(e){return Pt(e)?Xt(e.__v_raw):!!(e&&e.__v_isReactive)}function Pt(e){return!!(e&&e.__v_isReadonly)}function ut(e){return!!(e&&e.__v_isShallow)}function si(e){return e?!!e.__v_raw:!1}function ve(e){const t=e&&e.__v_raw;return t?ve(t):e}function Ar(e){return!be(e,"__v_skip")&&Object.isExtensible(e)&&_l(e,"__v_skip",!0),e}const ct=e=>Se(e)?bn(e):e,Qt=e=>Se(e)?Po(e):e;function We(e){return e?e.__v_isRef===!0:!1}function pe(e){return Er(e,!1)}function Er(e,t){return We(e)?e:new Pr(e,t)}class Pr{constructor(t,n){this.dep=new ei,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:ve(t),this._value=n?t:ct(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||ut(t)||Pt(t);t=s?t:ve(t),Tt(t,n)&&(this._rawValue=t,this._value=s?t:ct(t),this.dep.trigger())}}function m(e){return We(e)?e.value:e}const Or={get:(e,t,n)=>t==="__v_raw"?e:m(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const o=e[t];return We(o)&&!We(n)?(o.value=n,!0):Reflect.set(e,t,n,s)}};function Hl(e){return Xt(e)?e:new Proxy(e,Or)}class Ir{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new ei(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Zn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&Ae!==this)return Tl(this,!0),!0}get value(){const t=this.dep.track();return Pl(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Mr(e,t,n=!1){let s,o;return ue(e)?s=e:(s=e.get,o=e.set),new Ir(s,o,n)}const bs={},As=new WeakMap;let un;function Lr(e,t=!1,n=un){if(n){let s=As.get(n);s||As.set(n,s=[]),s.push(e)}}function Dr(e,t,n=Te){const{immediate:s,deep:o,once:l,scheduler:a,augmentJob:r,call:d}=n,f=C=>o?C:ut(C)||o===!1||o===0?Ft(C,1):Ft(C);let c,y,b,k,I=!1,E=!1;if(We(e)?(y=()=>e.value,I=ut(e)):Xt(e)?(y=()=>f(e),I=!0):oe(e)?(E=!0,I=e.some(C=>Xt(C)||ut(C)),y=()=>e.map(C=>{if(We(C))return C.value;if(Xt(C))return f(C);if(ue(C))return d?d(C,2):C()})):ue(e)?t?y=d?()=>d(e,2):e:y=()=>{if(b){Ht();try{b()}finally{Ut()}}const C=un;un=c;try{return d?d(e,3,[k]):e(k)}finally{un=C}}:y=At,t&&o){const C=y,V=o===!0?1/0:o;y=()=>Ft(C(),V)}const x=ur(),S=()=>{c.stop(),x&&x.active&&zo(x.effects,c)};if(l&&t){const C=t;t=(...V)=>{const O=C(...V);return S(),O}}let M=E?new Array(e.length).fill(bs):bs;const G=C=>{if(!(!(c.flags&1)||!c.dirty&&!C))if(t){const V=c.run();if(C||o||I||(E?V.some((O,A)=>Tt(O,M[A])):Tt(V,M))){b&&b();const O=un;un=c;try{const A=[V,M===bs?void 0:E&&M[0]===bs?[]:M,k];M=V,d?d(t,3,A):t(...A)}finally{un=O}}}else c.run()};return r&&r(G),c=new Sl(y),c.scheduler=a?()=>a(G,!1):G,k=C=>Lr(C,!1,c),b=c.onStop=()=>{const C=As.get(c);if(C){if(d)d(C,4);else for(const V of C)V();As.delete(c)}},t?s?G(!0):M=c.run():a?a(G.bind(null,!0),!0):c.run(),S.pause=c.pause.bind(c),S.resume=c.resume.bind(c),S.stop=S,S}function Ft(e,t=1/0,n){if(t<=0||!Se(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,We(e))Ft(e.value,t,n);else if(oe(e))for(let s=0;s{Ft(s,t,n)});else if(wl(e)){for(const s in e)Ft(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&Ft(e[s],t,n)}return e}function ds(e,t,n,s){try{return s?e(...s):e()}catch(o){zs(o,t,n)}}function mt(e,t,n,s){if(ue(e)){const o=ds(e,t,n,s);return o&&gl(o)&&o.catch(l=>{zs(l,t,n)}),o}if(oe(e)){const o=[];for(let l=0;l>>1,o=Ye[s],l=es(o);l=es(n)?Ye.push(e):Ye.splice(Nr(t),0,e),e.flags|=1,jl()}}function jl(){Es||(Es=Ul.then(Vl))}function Rr(e){if(!oe(e))Yt&&e.id===-1?Yt.splice($n+1,0,e):e.flags&1||(Sn.push(e),e.flags|=1);else for(let t=0;tes(n)-es(s));if(Sn.length=0,Yt){for(let n=0;ne.id==null?e.flags&2?-1:1/0:e.id;function Vl(e){try{for($t=0;$t{s._d&&Ni(-1);const l=Ps(t),a=hn.length;let r;try{r=e(...o)}finally{for(let d=hn.length;d>a;d--)va();Ps(l),s._d&&Ni(1)}return r};return s._n=!0,s._c=!0,s._d=!0,s}function we(e,t){if(nt===null)return e;const n=eo(nt),s=e.dirs||(e.dirs=[]);for(let o=0;o1)return n&&ue(t)?t.call(s&&s.proxy):t}}const jr=Symbol.for("v-scx"),Br=()=>$s(jr);function pn(e,t,n){return Kl(e,t,n)}function Kl(e,t,n=Te){const{immediate:s,deep:o,flush:l,once:a}=n,r=Ue({},n),d=t&&s||!t&&l!=="post";let f;if(ss){if(l==="sync"){const k=Br();f=k.__watcherHandles||(k.__watcherHandles=[])}else if(!d){const k=()=>{};return k.stop=At,k.resume=At,k.pause=At,k}}const c=Be;r.call=(k,I,E)=>mt(k,c,I,E);let y=!1;l==="post"?r.scheduler=k=>{Ge(k,c&&c.suspense)}:l!=="sync"&&(y=!0,r.scheduler=(k,I)=>{I?k():oi(k)}),r.augmentJob=k=>{t&&(k.flags|=4),y&&(k.flags|=2,c&&(k.id=c.uid,k.i=c))};const b=Dr(e,t,r);return ss&&(f?f.push(b):d&&b()),b}function Vr(e,t,n){const s=this.proxy,o=Ie(e)?e.includes(".")?ql(s,e):()=>s[e]:e.bind(s,s);let l;ue(t)?l=t:(l=t.handler,n=t);const a=fs(this),r=Kl(o,l.bind(s),n);return a(),r}function ql(e,t){const n=t.split(".");return()=>{let s=e;for(let o=0;oe.__isTeleport,fo=Symbol("_leaveCb");function Kr(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==jt){t=n;break}}return t}function Yl(e){if(!li(e))return Gs(e.type)&&e.children?Kr(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&ue(n.default))return n.default()}}function ii(e,t){if(e.shapeFlag&6&&e.component){e.transition=t;const n=e.component.subTree;ii(Gs(n.type)&&Yl(n)||n,t)}else e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ee(e,t){return ue(e)?Ue({name:e.name},t,{setup:e}):e}function zl(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Ci(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const Os=new WeakMap;function Jn(e,t,n,s,o=!1){if(oe(e)){e.forEach((E,x)=>Jn(E,t&&(oe(t)?t[x]:t),n,s,o));return}if(Gn(s)&&!o){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Jn(e,t,n,s.component.subTree);return}const l=s.shapeFlag&4?eo(s.component):s.el,a=o?null:l,{i:r,r:d}=e,f=t&&t.r,c=r.refs===Te?r.refs={}:r.refs,y=r.setupState,b=ve(y),k=y===Te?yl:E=>Ci(c,E)?!1:be(b,E),I=(E,x)=>!(x&&Ci(c,x));if(f!=null&&f!==d){if(Ti(t),Ie(f))c[f]=null,k(f)&&(y[f]=null);else if(We(f)){const E=t;I(f,E.k)&&(f.value=null),E.k&&(c[E.k]=null)}}if(ue(d))ds(d,r,12,[a,c]);else{const E=Ie(d),x=We(d);if(E||x){const S=()=>{if(e.f){const M=E?k(d)?y[d]:c[d]:I()||!e.k?d.value:c[e.k];if(o)oe(M)&&zo(M,l);else if(oe(M))M.includes(l)||M.push(l);else if(E)c[d]=[l],k(d)&&(y[d]=c[d]);else{const G=[l];I(d,e.k)&&(d.value=G),e.k&&(c[e.k]=G)}}else E?(c[d]=a,k(d)&&(y[d]=a)):x&&(I(d,e.k)&&(d.value=a),e.k&&(c[e.k]=a))};if(a){const M=()=>{S(),Os.delete(e)};M.id=-1,Os.set(e,M),Ge(M,n)}else Ti(e),S()}}}function Ti(e){const t=Os.get(e);t&&(t.flags|=8,Os.delete(e))}qs().requestIdleCallback;qs().cancelIdleCallback;const Gn=e=>!!e.type.__asyncLoader,li=e=>e.type.__isKeepAlive;function qr(e,t){Jl(e,"a",t)}function Yr(e,t){Jl(e,"da",t)}function Jl(e,t,n=Be){const s=e.__wdc||(e.__wdc=()=>{let o=n;for(;o;){if(o.isDeactivated)return;o=o.parent}return e()});if(Xs(t,s,n),n){let o=n.parent;for(;o&&o.parent;)li(o.parent.vnode)&&zr(s,t,n,o),o=o.parent}}function zr(e,t,n,s){const o=Xs(t,e,s,!0);Gl(()=>{zo(s[t],o)},n)}function Xs(e,t,n=Be,s=!1){if(n){const o=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...a)=>{Ht();const r=fs(n),d=mt(t,n,e,a);return r(),Ut(),d});return s?o.unshift(l):o.push(l),l}}const Wt=e=>(t,n=Be)=>{(!ss||e==="sp")&&Xs(e,(...s)=>t(...s),n)},Jr=Wt("bm"),_n=Wt("m"),Gr=Wt("bu"),Xr=Wt("u"),In=Wt("bum"),Gl=Wt("um"),Zr=Wt("sp"),Qr=Wt("rtg"),eu=Wt("rtc");function tu(e,t=Be){Xs("ec",e,t)}const nu="components",Xl=Symbol.for("v-ndc");function su(e){return Ie(e)?ou(nu,e,!1)||e:e||Xl}function ou(e,t,n=!0,s=!1){const o=nt||Be;if(o){const l=o.type;{const r=Bu(l,!1);if(r&&(r===t||r===ze(t)||r===Ws(ze(t))))return l}const a=Ai(o[e]||l[e],t)||Ai(o.appContext[e],t);return!a&&s?l:a}}function Ai(e,t){return e&&(e[t]||e[ze(t)]||e[Ws(ze(t))])}function le(e,t,n,s){let o;const l=n,a=oe(e);if(a||Ie(e)){const r=a&&Xt(e);let d=!1,f=!1;r&&(d=!ut(e),f=Pt(e),e=Ys(e)),o=new Array(e.length);for(let c=0,y=e.length;ct(r,d,void 0,l));else{const r=Object.keys(e);o=new Array(r.length);for(let d=0,f=r.length;de?ba(e)?eo(e):Oo(e.parent):null,Xn=Ue(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Oo(e.parent),$root:e=>Oo(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Ql(e),$forceUpdate:e=>e.f||(e.f=()=>{oi(e.update)}),$nextTick:e=>e.n||(e.n=Js.bind(e.proxy)),$watch:e=>Vr.bind(e)}),po=(e,t)=>e!==Te&&!e.__isScriptSetup&&be(e,t),iu={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:o,props:l,accessCache:a,type:r,appContext:d}=e;if(t[0]!=="$"){const b=a[t];if(b!==void 0)switch(b){case 1:return s[t];case 2:return o[t];case 4:return n[t];case 3:return l[t]}else{if(po(s,t))return a[t]=1,s[t];if(o!==Te&&be(o,t))return a[t]=2,o[t];if(be(l,t))return a[t]=3,l[t];if(n!==Te&&be(n,t))return a[t]=4,n[t];Io&&(a[t]=0)}}const f=Xn[t];let c,y;if(f)return t==="$attrs"&&je(e.attrs,"get",""),f(e);if((c=r.__cssModules)&&(c=c[t]))return c;if(n!==Te&&be(n,t))return a[t]=4,n[t];if(y=d.config.globalProperties,be(y,t))return y[t]},set({_:e},t,n){const{data:s,setupState:o,ctx:l}=e;return po(o,t)?(o[t]=n,!0):s!==Te&&be(s,t)?(s[t]=n,!0):be(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:o,props:l,type:a}},r){let d;return!!(n[r]||e!==Te&&r[0]!=="$"&&be(e,r)||po(t,r)||be(l,r)||be(s,r)||be(Xn,r)||be(o.config.globalProperties,r)||(d=a.__cssModules)&&d[r])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:be(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Ei(e){return oe(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Io=!0;function lu(e){const t=Ql(e),n=e.proxy,s=e.ctx;Io=!1,t.beforeCreate&&Pi(t.beforeCreate,e,"bc");const{data:o,computed:l,methods:a,watch:r,provide:d,inject:f,created:c,beforeMount:y,mounted:b,beforeUpdate:k,updated:I,activated:E,deactivated:x,beforeDestroy:S,beforeUnmount:M,destroyed:G,unmounted:C,render:V,renderTracked:O,renderTriggered:A,errorCaptured:w,serverPrefetch:T,expose:te,inheritAttrs:$e,components:Me,directives:X,filters:j}=t;if(f&&au(f,s,null),a)for(const de in a){const he=a[de];ue(he)&&(s[de]=he.bind(n))}if(o){const de=o.call(n,n);Se(de)&&(e.data=bn(de))}if(Io=!0,l)for(const de in l){const he=l[de],Q=ue(he)?he.bind(n,n):ue(he.get)?he.get.bind(n,n):At,K=!ue(he)&&ue(he.set)?he.set.bind(n):At,B=$({get:Q,set:K});Object.defineProperty(s,de,{enumerable:!0,configurable:!0,get:()=>B.value,set:ne=>B.value=ne})}if(r)for(const de in r)Zl(r[de],s,n,de);if(d){const de=ue(d)?d.call(n):d;Reflect.ownKeys(de).forEach(he=>{Ur(he,de[he])})}c&&Pi(c,e,"c");function ce(de,he){oe(he)?he.forEach(Q=>de(Q.bind(n))):he&&de(he.bind(n))}if(ce(Jr,y),ce(_n,b),ce(Gr,k),ce(Xr,I),ce(qr,E),ce(Yr,x),ce(tu,w),ce(eu,O),ce(Qr,A),ce(In,M),ce(Gl,C),ce(Zr,T),oe(te))if(te.length){const de=e.exposed||(e.exposed={});te.forEach(he=>{Object.defineProperty(de,he,{get:()=>n[he],set:Q=>n[he]=Q,enumerable:!0})})}else e.exposed||(e.exposed={});V&&e.render===At&&(e.render=V),$e!=null&&(e.inheritAttrs=$e),Me&&(e.components=Me),X&&(e.directives=X),T&&zl(e)}function au(e,t,n=At){oe(e)&&(e=Mo(e));for(const s in e){const o=e[s];let l;Se(o)?"default"in o?l=$s(o.from||s,o.default,!0):l=$s(o.from||s):l=$s(o),We(l)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>l.value,set:a=>l.value=a}):t[s]=l}}function Pi(e,t,n){mt(oe(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function Zl(e,t,n,s){let o=s.includes(".")?ql(n,s):()=>n[s];if(Ie(e)){const l=t[e];ue(l)&&pn(o,l)}else if(ue(e))pn(o,e.bind(n));else if(Se(e))if(oe(e))e.forEach(l=>Zl(l,t,n,s));else{const l=ue(e.handler)?e.handler.bind(n):t[e.handler];ue(l)&&pn(o,l,e)}}function Ql(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:o,optionsCache:l,config:{optionMergeStrategies:a}}=e.appContext,r=l.get(t);let d;return r?d=r:!o.length&&!n&&!s?d=t:(d={},o.length&&o.forEach(f=>Is(d,f,a,!0)),Is(d,t,a)),Se(t)&&l.set(t,d),d}function Is(e,t,n,s=!1){const{mixins:o,extends:l}=t;l&&Is(e,l,n,!0),o&&o.forEach(a=>Is(e,a,n,!0));for(const a in t)if(!(s&&a==="expose")){const r=ru[a]||n&&n[a];e[a]=r?r(e[a],t[a]):t[a]}return e}const ru={data:Oi,props:Ii,emits:Ii,methods:Un,computed:Un,beforeCreate:Ke,created:Ke,beforeMount:Ke,mounted:Ke,beforeUpdate:Ke,updated:Ke,beforeDestroy:Ke,beforeUnmount:Ke,destroyed:Ke,unmounted:Ke,activated:Ke,deactivated:Ke,errorCaptured:Ke,serverPrefetch:Ke,components:Un,directives:Un,watch:cu,provide:Oi,inject:uu};function Oi(e,t){return t?e?function(){return Ue(ue(e)?e.call(this,this):e,ue(t)?t.call(this,this):t)}:t:e}function uu(e,t){return Un(Mo(e),Mo(t))}function Mo(e){if(oe(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${ze(t)}Modifiers`]||e[`${on(t)}Modifiers`];function hu(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||Te;let o=n;const l=t.startsWith("update:"),a=l&&pu(s,t.slice(7));a&&(a.trim&&(o=n.map(c=>Ie(c)?c.trim():c)),a.number&&(o=o.map(Ks)));let r,d=s[r=lo(t)]||s[r=lo(ze(t))];!d&&l&&(d=s[r=lo(on(t))]),d&&mt(d,e,6,o);const f=s[r+"Once"];if(f){if(!e.emitted)e.emitted={};else if(e.emitted[r])return;e.emitted[r]=!0,mt(f,e,6,o)}}const vu=new WeakMap;function ta(e,t,n=!1){const s=n?vu:t.emitsCache,o=s.get(e);if(o!==void 0)return o;const l=e.emits;let a={},r=!1;if(!ue(e)){const d=f=>{const c=ta(f,t,!0);c&&(r=!0,Ue(a,c))};!n&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}return!l&&!r?(Se(e)&&s.set(e,null),null):(oe(l)?l.forEach(d=>a[d]=null):Ue(a,l),Se(e)&&s.set(e,a),a)}function Zs(e,t){return!e||!js(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),be(e,t[0].toLowerCase()+t.slice(1))||be(e,on(t))||be(e,t))}function Mi(e){const{type:t,vnode:n,proxy:s,withProxy:o,propsOptions:[l],slots:a,attrs:r,emit:d,render:f,renderCache:c,props:y,data:b,setupState:k,ctx:I,inheritAttrs:E}=e,x=Ps(e);let S,M;try{if(n.shapeFlag&4){const C=o||s,V=C;S=Ct(f.call(V,C,c,y,k,b,I)),M=r}else{const C=t;S=Ct(C.length>1?C(y,{attrs:r,slots:a,emit:d}):C(y,null)),M=t.props?r:mu(r)}}catch(C){hn.length=0,zs(C,e,1),S=_e(jt)}let G=S;if(M&&E!==!1){const C=Object.keys(M),{shapeFlag:V}=G;C.length&&V&7&&(l&&C.some(Bs)&&(M=yu(M,l)),G=En(G,M,!1,!0))}if(n.dirs&&(G=En(G,null,!1,!0),G.dirs=G.dirs?G.dirs.concat(n.dirs):n.dirs),n.transition){const C=Gs(G.type)&&Yl(G)||G;ii(C,n.transition)}return S=G,Ps(x),S}const mu=e=>{let t;for(const n in e)(n==="class"||n==="style"||js(n))&&((t||(t={}))[n]=e[n]);return t},yu=(e,t)=>{const n={};for(const s in e)(!Bs(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function gu(e,t,n){const{props:s,children:o,component:l}=e,{props:a,children:r,patchFlag:d}=t,f=l.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&d>=0){if(d&1024)return!0;if(d&16)return s?Li(s,a,f):!!a;if(d&8){const c=t.dynamicProps;for(let y=0;yObject.create(sa),ia=e=>Object.getPrototypeOf(e)===sa;function wu(e,t,n,s=!1){const o={},l=oa();e.propsDefaults=Object.create(null),la(e,t,o,l);for(const a in e.propsOptions[0])a in o||(o[a]=void 0);n?e.props=s?o:Tr(o):e.type.props?e.props=o:e.props=l,e.attrs=l}function _u(e,t,n,s){const{props:o,attrs:l,vnode:{patchFlag:a}}=e,r=ve(o),[d]=e.propsOptions;let f=!1;if((s||a>0)&&!(a&16)){if(a&8){const c=e.vnode.dynamicProps;for(let y=0;y{d=!0;const[b,k]=aa(y,t,!0);Ue(a,b),k&&r.push(...k)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!l&&!d)return Se(e)&&s.set(e,dn),dn;if(oe(l))for(let c=0;ce==="_"||e==="_ctx"||e==="$stable",ri=e=>oe(e)?e.map(Ct):[Ct(e)],$u=(e,t,n)=>{if(t._n)return t;const s=Hr((...o)=>ri(t(...o)),n);return s._c=!1,s},ra=(e,t,n)=>{const s=e._ctx;for(const o in e){if(ai(o))continue;const l=e[o];if(ue(l))t[o]=$u(o,l,s);else if(l!=null){const a=ri(l);t[o]=()=>a}}},ua=(e,t)=>{const n=ri(t);e.slots.default=()=>n},ca=(e,t,n)=>{for(const s in t)(n||!ai(s))&&(e[s]=t[s])},xu=(e,t,n)=>{const s=e.slots=oa();if(e.vnode.shapeFlag&32){const o=t._;o?(ca(s,t,n),n&&_l(s,"_",o,!0)):ra(t,s)}else t&&ua(e,t)},Su=(e,t,n)=>{const{vnode:s,slots:o}=e;let l=!0,a=Te;if(s.shapeFlag&32){const r=t._;r?n&&r===1?l=!1:ca(o,t,n):(l=!t.$stable,ra(t,o)),a=t}else t&&(ua(e,t),a={default:1});if(l)for(const r in o)!ai(r)&&a[r]==null&&delete o[r]},Ge=Pu;function Cu(e){return Tu(e)}function Tu(e,t){const n=qs();n.__VUE__=!0;const{insert:s,remove:o,patchProp:l,createElement:a,createText:r,createComment:d,setText:f,setElementText:c,parentNode:y,nextSibling:b,setScopeId:k=At,insertStaticContent:I}=e,E=(g,_,P,H=null,L=null,F=null,Y=void 0,q=null,W=!!_.dynamicChildren)=>{if(g===_)return;g&&!Nn(g,_)&&(H=fe(g),ne(g,L,F,!0),g=null),_.patchFlag===-2&&(W=!1,_.dynamicChildren=null),_.dynamicChildren&&g&&g.dynamicChildren&&g.dynamicChildren.hasOnce&&(_.dynamicChildren===dn&&(_.dynamicChildren=[]),_.dynamicChildren.hasOnce=!0);const{type:D,ref:se,shapeFlag:J}=_;switch(D){case Qs:x(g,_,P,H);break;case jt:S(g,_,P,H);break;case xs:g==null&&M(_,P,H,Y);break;case U:Me(g,_,P,H,L,F,Y,q,W);break;default:J&1?V(g,_,P,H,L,F,Y,q,W):J&6?X(g,_,P,H,L,F,Y,q,W):(J&64||J&128)&&D.process(g,_,P,H,L,F,Y,q,W,ln)}se!=null&&L?Jn(se,g&&g.ref,F,_||g,!_):se==null&&g&&g.ref!=null&&Jn(g.ref,null,F,g,!0)},x=(g,_,P,H)=>{if(g==null)s(_.el=r(_.children),P,H);else{const L=_.el=g.el;_.children!==g.children&&f(L,_.children)}},S=(g,_,P,H)=>{g==null?s(_.el=d(_.children||""),P,H):_.el=g.el},M=(g,_,P,H)=>{[g.el,g.anchor]=I(g.children,_,P,H,g.el,g.anchor)},G=({el:g,anchor:_},P,H)=>{let L;for(;g&&g!==_;)L=b(g),s(g,P,H),g=L;s(_,P,H)},C=({el:g,anchor:_})=>{let P;for(;g&&g!==_;)P=b(g),o(g),g=P;o(_)},V=(g,_,P,H,L,F,Y,q,W)=>{if(_.type==="svg"?Y="svg":_.type==="math"&&(Y="mathml"),g==null)O(_,P,H,L,F,Y,q,W);else{const D=g.el&&g.el._isVueCE?g.el:null;try{D&&D._beginPatch(),T(g,_,L,F,Y,q,W)}finally{D&&D._endPatch()}}},O=(g,_,P,H,L,F,Y,q)=>{let W,D;const{props:se,shapeFlag:J,transition:ee,dirs:ae}=g;if(W=g.el=a(g.type,F,se&&se.is,se),J&8?c(W,g.children):J&16&&w(g.children,W,null,H,L,ho(g,F),Y,q),ae&&an(g,null,H,"created"),A(W,g,g.scopeId,Y,H),se){for(const Ce in se)Ce!=="value"&&!qn(Ce)&&l(W,Ce,null,se[Ce],F,H);"value"in se&&l(W,"value",null,se.value,F),(D=se.onVnodeBeforeMount)&&kt(D,H,g)}ae&&an(g,null,H,"beforeMount");const ye=Au(L,ee);ye&&ee.beforeEnter(W),s(W,_,P),((D=se&&se.onVnodeMounted)||ye||ae)&&Ge(()=>{D&&kt(D,H,g),ye&&ee.enter(W),ae&&an(g,null,H,"mounted")},L)},A=(g,_,P,H,L)=>{if(P&&k(g,P),H)for(let F=0;F{for(let D=W;D{const q=_.el=g.el;let{patchFlag:W,dynamicChildren:D,dirs:se}=_;W|=g.patchFlag&16;const J=g.props||Te,ee=_.props||Te;let ae;if(P&&rn(P,!1),(ae=ee.onVnodeBeforeUpdate)&&kt(ae,P,_,g),se&&an(_,g,P,"beforeUpdate"),P&&rn(P,!0),D&&(!g.dynamicChildren||g.dynamicChildren.length!==D.length)&&(W=0,Y=!1,D=null),(J.innerHTML&&ee.innerHTML==null||J.textContent&&ee.textContent==null)&&c(q,""),D?te(g.dynamicChildren,D,q,P,H,ho(_,L),F):Y||he(g,_,q,null,P,H,ho(_,L),F,!1),W>0){if(W&16)$e(q,J,ee,P,L);else if(W&2&&J.class!==ee.class&&l(q,"class",null,ee.class,L),W&4&&l(q,"style",J.style,ee.style,L),W&8){const ye=_.dynamicProps;for(let Ce=0;Ce{ae&&kt(ae,P,_,g),se&&an(_,g,P,"updated")},H)},te=(g,_,P,H,L,F,Y)=>{for(let q=0;q<_.length;q++){const W=g[q],D=_[q],se=W.el&&(W.type===U||!Nn(W,D)||W.shapeFlag&198)?y(W.el):P;E(W,D,se,null,H,L,F,Y,!0)}},$e=(g,_,P,H,L)=>{if(_!==P){if(_!==Te)for(const F in _)!qn(F)&&!(F in P)&&l(g,F,_[F],null,L,H);for(const F in P){if(qn(F))continue;const Y=P[F],q=_[F];Y!==q&&F!=="value"&&l(g,F,q,Y,L,H)}"value"in P&&l(g,"value",_.value,P.value,L)}},Me=(g,_,P,H,L,F,Y,q,W)=>{const D=_.el=g?g.el:r(""),se=_.anchor=g?g.anchor:r("");let{patchFlag:J,dynamicChildren:ee,slotScopeIds:ae}=_;ae&&(q=q?q.concat(ae):ae),g==null?(s(D,P,H),s(se,P,H),w(_.children||[],P,se,L,F,Y,q,W)):J>0&&J&64&&ee&&g.dynamicChildren&&g.dynamicChildren.length===ee.length?(te(g.dynamicChildren,ee,P,L,F,Y,q),(_.key!=null||L&&_===L.subTree)&&da(g,_,!0)):he(g,_,P,se,L,F,Y,q,W)},X=(g,_,P,H,L,F,Y,q,W)=>{_.slotScopeIds=q,g==null?_.shapeFlag&512?L.ctx.activate(_,P,H,Y,W):j(_,P,H,L,F,Y,W):z(g,_,W)},j=(g,_,P,H,L,F,Y)=>{const q=g.component=Fu(g,H,L);if(li(g)&&(q.ctx.renderer=ln),Ru(q,!1,Y),q.asyncDep){if(L&&L.registerDep(q,ce,Y),!g.el){const W=q.subTree=_e(jt);S(null,W,_,P),g.placeholder=W.el}}else ce(q,g,_,P,L,F,Y)},z=(g,_,P)=>{const H=_.component=g.component;if(gu(g,_,P))if(H.asyncDep&&!H.asyncResolved){_.el=g.el,de(H,_,P);return}else H.next=_,H.update();else _.el=g.el,H.vnode=_},ce=(g,_,P,H,L,F,Y)=>{const q=()=>{if(g.isMounted){let{next:J,bu:ee,u:ae,parent:ye,vnode:Ce}=g;{const wt=fa(g);if(wt){J&&(J.el=Ce.el,de(g,J,Y)),wt.asyncDep.then(()=>{Ge(()=>{g.isUnmounted||D()},L)});return}}let xe=J,De;rn(g,!1),J?(J.el=Ce.el,de(g,J,Y)):J=Ce,ee&&ks(ee),(De=J.props&&J.props.onVnodeBeforeUpdate)&&kt(De,ye,J,Ce),rn(g,!0);const Ne=Mi(g),bt=g.subTree;g.subTree=Ne,E(bt,Ne,y(bt.el),fe(bt),g,L,F),J.el=Ne.el,xe===null&&bu(g,Ne.el),ae&&Ge(ae,L),(De=J.props&&J.props.onVnodeUpdated)&&Ge(()=>kt(De,ye,J,Ce),L)}else{let J;const{el:ee,props:ae}=_,{bm:ye,m:Ce,parent:xe,root:De,type:Ne}=g,bt=Gn(_);rn(g,!1),ye&&ks(ye),!bt&&(J=ae&&ae.onVnodeBeforeMount)&&kt(J,xe,_),rn(g,!0);{De.ce&&De.ce._hasShadowRoot()&&De.ce._injectChildStyle(Ne,g.parent?g.parent.type:void 0);const wt=g.subTree=Mi(g);E(null,wt,P,H,g,L,F),_.el=wt.el}if(Ce&&Ge(Ce,L),!bt&&(J=ae&&ae.onVnodeMounted)){const wt=_;Ge(()=>kt(J,xe,wt),L)}(_.shapeFlag&256||xe&&Gn(xe.vnode)&&xe.vnode.shapeFlag&256)&&g.a&&Ge(g.a,L),g.isMounted=!0,_=P=H=null}};g.scope.on();const W=g.effect=new Sl(q);g.scope.off();const D=g.update=W.run.bind(W),se=g.job=W.runIfDirty.bind(W);se.i=g,se.id=g.uid,W.scheduler=()=>oi(se),rn(g,!0),D()},de=(g,_,P)=>{_.component=g;const H=g.vnode.props;g.vnode=_,g.next=null,_u(g,_.props,H,P),Su(g,_.children,P),Ht(),Si(g),Ut()},he=(g,_,P,H,L,F,Y,q,W=!1)=>{const D=g&&g.children,se=g?g.shapeFlag:0,J=_.children,{patchFlag:ee,shapeFlag:ae}=_;if(ee>0){if(ee&128){K(D,J,P,H,L,F,Y,q,W);return}else if(ee&256){Q(D,J,P,H,L,F,Y,q,W);return}}ae&8?(se&16&&N(D,L,F),J!==D&&c(P,J)):se&16?ae&16?K(D,J,P,H,L,F,Y,q,W):N(D,L,F,!0):(se&8&&c(P,""),ae&16&&w(J,P,H,L,F,Y,q,W))},Q=(g,_,P,H,L,F,Y,q,W)=>{g=g||dn,_=_||dn;const D=g.length,se=_.length,J=Math.min(D,se);let ee;for(ee=0;eese?N(g,L,F,!0,!1,J):w(_,P,H,L,F,Y,q,W,J)},K=(g,_,P,H,L,F,Y,q,W)=>{let D=0;const se=_.length;let J=g.length-1,ee=se-1;for(;D<=J&&D<=ee;){const ae=g[D],ye=_[D]=W?Mt(_[D]):Ct(_[D]);if(Nn(ae,ye))E(ae,ye,P,null,L,F,Y,q,W);else break;D++}for(;D<=J&&D<=ee;){const ae=g[J],ye=_[ee]=W?Mt(_[ee]):Ct(_[ee]);if(Nn(ae,ye))E(ae,ye,P,null,L,F,Y,q,W);else break;J--,ee--}if(D>J){if(D<=ee){const ae=ee+1,ye=aeee)for(;D<=J;)ne(g[D],L,F,!0),D++;else{const ae=D,ye=D,Ce=new Map;for(D=ye;D<=ee;D++){const et=_[D]=W?Mt(_[D]):Ct(_[D]);et.key!=null&&Ce.set(et.key,D)}let xe,De=0;const Ne=ee-ye+1;let bt=!1,wt=0;const Dn=new Array(Ne);for(D=0;D=Ne){ne(et,L,F,!0);continue}let _t;if(et.key!=null)_t=Ce.get(et.key);else for(xe=ye;xe<=ee;xe++)if(Dn[xe-ye]===0&&Nn(et,_[xe])){_t=xe;break}_t===void 0?ne(et,L,F,!0):(Dn[_t-ye]=D+1,_t>=wt?wt=_t:bt=!0,E(et,_[_t],P,null,L,F,Y,q,W),De++)}const mi=bt?Eu(Dn):dn;for(xe=mi.length-1,D=Ne-1;D>=0;D--){const et=ye+D,_t=_[et],yi=_[et+1],gi=et+1{const{el:F,type:Y,transition:q,children:W,shapeFlag:D}=g;if(D&6){B(g.component.subTree,_,P,H);return}if(D&128){g.suspense.move(_,P,H);return}if(D&64){Y.move(g,_,P,ln);return}if(Y===U){s(F,_,P);for(let J=0;Jq.enter(F),L));else{const{leave:J,delayLeave:ee,afterLeave:ae}=q,ye=()=>{g.ctx.isUnmounted?o(F):s(F,_,P)},Ce=()=>{const xe=F._isLeaving||!!F[fo];F._isLeaving&&F[fo](!0),q.persisted&&!xe?ye():J(F,()=>{ye(),ae&&ae()})};ee?ee(F,ye,Ce):Ce()}else s(F,_,P)},ne=(g,_,P,H=!1,L=!1)=>{const{type:F,props:Y,ref:q,children:W,dynamicChildren:D,shapeFlag:se,patchFlag:J,dirs:ee,cacheIndex:ae,memo:ye}=g;if((J===-2||D&&D.hasOnce)&&(L=!1),q!=null&&(Ht(),Jn(q,null,P,g,!0),Ut()),ae!=null&&(!g.ctx||g.ctx===_)&&(_.renderCache[ae]=void 0),se&256){_.ctx.deactivate(g);return}const Ce=se&1&&ee,xe=!Gn(g);let De;if(xe&&(De=Y&&Y.onVnodeBeforeUnmount)&&kt(De,_,g),se&6)me(g.component,P,H);else{if(se&128){g.suspense.unmount(P,H);return}Ce&&an(g,null,_,"beforeUnmount"),se&64?g.type.remove(g,_,P,ln,H):D&&!D.hasOnce&&(F!==U||J>0&&J&64)?N(D,_,P,!1,!0):(F===U&&J&384||!L&&se&16)&&N(W,_,P),H&&Qe(g)}const Ne=ye!=null&&ae==null;(xe&&(De=Y&&Y.onVnodeUnmounted)||Ce||Ne)&&Ge(()=>{De&&kt(De,_,g),Ce&&an(g,null,_,"unmounted"),Ne&&(g.el=null)},P)},Qe=g=>{const{type:_,el:P,anchor:H,transition:L}=g;if(_===U){Mn(P,H);return}if(_===xs){C(g),L&&!L.persisted&&L.afterLeave&&L.afterLeave();return}const F=()=>{o(P),L&&!L.persisted&&L.afterLeave&&L.afterLeave()};if(g.shapeFlag&1&&L&&!L.persisted){const{leave:Y,delayLeave:q}=L,W=()=>Y(P,F);q?q(g.el,F,W):W()}else F()},Mn=(g,_)=>{let P;for(;g!==_;)P=b(g),o(g),g=P;o(_)},me=(g,_,P)=>{const{bum:H,scope:L,job:F,subTree:Y,um:q,m:W,a:D}=g;Fi(W),Fi(D),H&&ks(H),L.stop(),F?(F.flags|=8,ne(Y,g,_,P)):g.vnode.el&&Y&&(Y.transition=g.vnode.transition,ne(Y,g,_,P)),q&&Ge(q,_),Ge(()=>{g.isUnmounted=!0},_)},N=(g,_,P,H=!1,L=!1,F=0)=>{for(let Y=F;Y{if(g.shapeFlag&6)return fe(g.component.subTree);if(g.shapeFlag&128)return g.suspense.next();const _=b(g.anchor||g.el),P=_&&_[Wr];return P?b(P):_};let at=!1;const Ln=(g,_,P)=>{let H;g==null?_._vnode&&(ne(_._vnode,null,null,!0),H=_._vnode.component):E(_._vnode||null,g,_,null,null,null,P),_._vnode=g,at||(at=!0,Si(H),Bl(),at=!1)},ln={p:E,um:ne,m:B,r:Qe,mt:j,mc:w,pc:he,pbc:te,n:fe,o:e};return{render:Ln,hydrate:void 0,createApp:fu(Ln)}}function ho({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function rn({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Au(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function da(e,t,n=!1){const s=e.children,o=t.children;if(oe(s)&&oe(o))for(let l=0;l>1,e[n[r]]0&&(t[s]=n[l-1]),n[l]=s)}}for(l=n.length,a=n[l-1];l-- >0;)n[l]=a,a=t[a];return n}function fa(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:fa(t)}function Fi(e){if(e)for(let t=0;te.__isSuspense;function Pu(e,t){t&&t.pendingBranch?oe(e)?t.effects.push(...e):t.effects.push(e):Rr(e)}const U=Symbol.for("v-fgt"),Qs=Symbol.for("v-txt"),jt=Symbol.for("v-cmt"),xs=Symbol.for("v-stc"),hn=[];let st=null;function p(e=!1){hn.push(st=e?null:[])}function va(){hn.pop(),st=hn[hn.length-1]||null}let ts=1;function Ni(e,t=!1){ts+=e,e<0&&st&&t&&(st.hasOnce=!0)}function ma(e){return e.dynamicChildren=ts>0?st||dn:null,va(),ts>0&&st&&st.push(e),e}function h(e,t,n,s,o,l){return ma(i(e,t,n,s,o,l,!0))}function Xe(e,t,n,s,o){return ma(_e(e,t,n,s,o,!0))}function ya(e){return e?e.__v_isVNode===!0:!1}function Nn(e,t){return e.type===t.type&&e.key===t.key}const ga=({key:e})=>e??null,Ss=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?Ie(e)||We(e)||ue(e)?{i:nt,r:e,k:t,f:!!n}:e:null);function i(e,t=null,n=null,s=0,o=null,l=e===U?0:1,a=!1,r=!1){const d={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&ga(t),ref:t&&Ss(t),scopeId:Wl,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:s,dynamicProps:o,dynamicChildren:null,appContext:null,ctx:nt};return r?(Ms(d,n),l&128&&e.normalize(d)):n&&(d.shapeFlag|=Ie(n)?8:16),ts>0&&!a&&st&&(d.patchFlag>0||l&6)&&d.patchFlag!==32&&st.push(d),d}const _e=Ou;function Ou(e,t=null,n=null,s=0,o=null,l=!1){if((!e||e===Xl)&&(e=jt),ya(e)){const r=En(e,t,!0);return n&&Ms(r,n),ts>0&&!l&&st&&(r.shapeFlag&6?st[st.indexOf(e)]=r:st.push(r)),r.patchFlag=-2,r}if(Vu(e)&&(e=e.__vccOpts),t){t=Iu(t);let{class:r,style:d}=t;r&&!Ie(r)&&(t.class=ie(r)),Se(d)&&(si(d)&&!oe(d)&&(d=Ue({},d)),t.style=Pe(d))}const a=Ie(e)?1:ha(e)?128:Gs(e)?64:Se(e)?4:ue(e)?2:0;return i(e,t,n,s,o,a,l,!0)}function Iu(e){return e?si(e)||ia(e)?Ue({},e):e:null}function En(e,t,n=!1,s=!1){const{props:o,ref:l,patchFlag:a,children:r,transition:d}=e,f=t?Mu(o||{},t):o,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:f,key:f&&ga(f),ref:t&&t.ref?n&&l?oe(l)?l.concat(Ss(t)):[l,Ss(t)]:Ss(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:r,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==U?a===-1?16:a|16:a,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:d,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&En(e.ssContent),ssFallback:e.ssFallback&&En(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce,cacheIndex:e.cacheIndex};return d&&s&&ii(c,d.clone(c)),c}function Z(e=" ",t=0){return _e(Qs,null,e,t)}function Cs(e,t){const n=_e(xs,null,e);return n.staticCount=t,n}function R(e="",t=!1){return t?(p(),Xe(jt,null,e)):_e(jt,null,e)}function Ct(e){return e==null||typeof e=="boolean"?_e(jt):oe(e)?_e(U,null,e.slice()):ya(e)?Mt(e):_e(Qs,null,String(e))}function Mt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:En(e)}function Ms(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(oe(t))n=16;else if(typeof t=="object")if(s&65){const o=t.default;o&&(o._c&&(o._d=!1),Ms(e,o()),o._c&&(o._d=!0));return}else{n=32;const o=t._;!o&&!ia(t)?t._ctx=nt:o===3&&nt&&(nt.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(ue(t)){if(s&65){Ms(e,{default:t});return}t={default:t,_ctx:nt},n=32}else t=String(t),s&64?(n=16,t=[Z(t)]):n=8;e.children=t,e.shapeFlag|=n}function Mu(...e){const t={};for(let n=0;nBe||nt;let Ls,ns;{const e=qs(),t=(n,s)=>{let o;return(o=e[n])||(o=e[n]=[]),o.push(s),l=>{o.length>1?o.forEach(a=>a(l)):o[0](l)}};Ls=t("__VUE_INSTANCE_SETTERS__",n=>Be=n),ns=t("__VUE_SSR_SETTERS__",n=>ss=n)}const fs=e=>{const t=Be;return Ls(e),e.scope.on(),()=>{e.scope.off(),Ls(t)}},Ri=()=>{Be&&Be.scope.off(),Ls(null)};function ba(e){return e.vnode.shapeFlag&4}let ss=!1;function Ru(e,t=!1,n=!1){t&&ns(t);const{props:s,children:o}=e.vnode,l=ba(e);wu(e,s,l,t),xu(e,o,n||t);const a=l?Hu(e,t):void 0;return t&&ns(!1),a}function Hu(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,iu);const{setup:s}=n;if(s){Ht();const o=e.setupContext=s.length>1?ju(e):null,l=fs(e),a=ds(s,e,0,[e.props,o]),r=gl(a);if(Ut(),l(),(r||e.sp)&&!Gn(e)&&zl(e),r){if(a.then(Ri,Ri),t)return a.then(d=>{ns(!0);try{Hi(e,d,t)}finally{ns(!1)}}).catch(d=>{zs(d,e,0)});e.asyncDep=a}else Hi(e,a)}else wa(e)}function Hi(e,t,n){ue(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:Se(t)&&(e.setupState=Hl(t)),wa(e)}function wa(e,t,n){const s=e.type;e.render||(e.render=s.render||At);{const o=fs(e);Ht();try{lu(e)}finally{Ut(),o()}}}const Uu={get(e,t){return je(e,"get",""),e[t]}};function ju(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Uu),slots:e.slots,emit:e.emit,expose:t}}function eo(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Hl(Ar(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Xn)return Xn[n](e)},has(t,n){return n in t||n in Xn}})):e.proxy}function Bu(e,t=!0){return ue(e)?e.displayName||e.name:e.name||t&&e.__name}function Vu(e){return ue(e)&&"__vccOpts"in e}const $=(e,t)=>Mr(e,t,ss),Wu="3.5.43";let Do;const Ui=typeof window<"u"&&window.trustedTypes;if(Ui)try{Do=Ui.createPolicy("vue",{createHTML:e=>e})}catch{}const _a=Do?e=>Do.createHTML(e):e=>e,Ku="http://www.w3.org/2000/svg",qu="http://www.w3.org/1998/Math/MathML",It=typeof document<"u"?document:null,ji=It&&It.createElement("template"),Yu={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const o=t==="svg"?It.createElementNS(Ku,e):t==="mathml"?It.createElementNS(qu,e):n?It.createElement(e,{is:n}):It.createElement(e);return e==="select"&&s&&s.multiple!=null&&o.setAttribute("multiple",s.multiple),o},createText:e=>It.createTextNode(e),createComment:e=>It.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>It.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,o,l){const a=n?n.previousSibling:t.lastChild;if(o&&(o===l||o.nextSibling))for(;t.insertBefore(o.cloneNode(!0),n),!(o===l||!(o=o.nextSibling)););else{ji.innerHTML=_a(s==="svg"?``:s==="mathml"?``:e);const r=ji.content;if(s==="svg"||s==="mathml"){const d=r.firstChild;for(;d.firstChild;)r.appendChild(d.firstChild);r.removeChild(d)}t.insertBefore(r,n)}return[a?a.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},zu=Symbol("_vtc");function Ju(e,t,n){const s=e[zu];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Bi=Symbol("_vod"),Gu=Symbol("_vsh"),Xu=Symbol(""),Zu=/(?:^|;)\s*display\s*:/;function Qu(e,t,n){const s=e.style,o=Ie(n);let l=!1;if(n&&!o){if(t)if(Ie(t))for(const a of t.split(";")){const r=a.slice(0,a.indexOf(":")).trim();n[r]==null&&jn(s,r,"")}else for(const a in t)n[a]==null&&jn(s,a,"");for(const a in n){a==="display"&&(l=!0);const r=n[a];r!=null?tc(e,a,!Ie(t)&&t?t[a]:void 0,r)||jn(s,a,r):jn(s,a,"")}}else if(o){if(t!==n){const a=s[Xu];a&&(n+=";"+a),s.cssText=n,l=Zu.test(n)}}else t&&e.removeAttribute("style");Bi in e&&(e[Bi]=l?s.display:"",e[Gu]&&(s.display="none"))}const ws=/\s*!important$/;function jn(e,t,n){if(oe(n))n.forEach(s=>jn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))ws.test(n)?e.setProperty(t,n.replace(ws,""),"important"):e.setProperty(t,n);else{const s=ec(e,t);ws.test(n)?e.setProperty(on(s),n.replace(ws,""),"important"):e[s]=n}}const Vi=["Webkit","Moz","ms"],vo={};function ec(e,t){const n=vo[t];if(n)return n;let s=ze(t);if(s!=="filter"&&s in e)return vo[t]=s;s=Ws(s);for(let o=0;omo||(ac.then(()=>mo=0),mo=Date.now());function uc(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const o=n.value;if(oe(o)){const l=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{l.call(s),s._stopped=!0};const a=o.slice(),r=[s];for(let d=0;de.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,cc=(e,t,n,s,o,l)=>{const a=o==="svg";t==="class"?Ju(e,s,a):t==="style"?Qu(e,n,s):js(t)?Bs(t)||sc(e,t,n,s,l):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):dc(e,t,s,a))?(qi(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Ki(e,t,s,a,l,t!=="value")):e._isVueCE&&(fc(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!Ie(s)))?qi(e,ze(t),s,l,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),Ki(e,t,s,a))};function dc(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&zi(t)&&ue(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const o=e.tagName;if(o==="IMG"||o==="VIDEO"||o==="CANVAS"||o==="SOURCE")return!1}return zi(t)&&Ie(n)?!1:t in e}function fc(e,t){const n=e._def.props;if(!n)return!1;const s=ze(t);return Array.isArray(n)?n.some(o=>ze(o)===s):Object.keys(n).some(o=>ze(o)===s)}const en=e=>{const t=e.props["onUpdate:modelValue"]||!1;return oe(t)?n=>ks(t,n):t};function pc(e){e.target.composing=!0}function Ji(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ot=Symbol("_assign"),_s=Symbol("_initialValue");function yo(e,t,n){return t&&(e=e.trim()),n&&(e=Ks(e)),e}const Fe={created(e,{modifiers:{lazy:t,trim:n,number:s}},o){e.parentNode&&(e.type==="text"?e[_s]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[_s]=e.defaultValue.replace(/\r\n?/g,`
+`))),e[ot]=en(o);const l=s||o.props&&o.props.type==="number";Nt(e,t?"change":"input",a=>{a.target.composing||e[ot](yo(e.value,n,l))}),(n||l)&&Nt(e,"change",()=>{e.value=yo(e.value,n,l)}),t||(Nt(e,"compositionstart",pc),Nt(e,"compositionend",Ji),Nt(e,"change",Ji))},mounted(e,{value:t,modifiers:{trim:n,number:s}}){const o=t??"",l=e[_s];delete e[_s],l!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==l?e[ot](yo(e.value,n,s)):e.value=o},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:o,number:l}},a){if(e[ot]=en(a),e.composing)return;const r=(l||e.type==="number")&&!/^0\d/.test(e.value)?Ks(e.value):e.value,d=t??"";if(r===d)return;const f=e.getRootNode();(f instanceof Document||f instanceof ShadowRoot)&&f.activeElement===e&&e.type!=="range"&&(s&&t===n||o&&e.value.trim()===d)||(e.value=d)}},hc={deep:!0,created(e,t,n){e[ot]=en(n),Nt(e,"change",()=>{const s=e._modelValue,o=Pn(e),l=e.checked,a=e[ot];if(oe(s)){const r=Go(s,o),d=r!==-1;if(l&&!d)a(s.concat(o));else if(!l&&d){const f=[...s];f.splice(r,1),a(f)}}else if(Rt(s)){const r=new Set(s);l?r.add(o):r.delete(o),a(r)}else a(ka(e,l))})},mounted:Gi,beforeUpdate(e,t,n){e[ot]=en(n),Gi(e,t,n)}};function Gi(e,{value:t,oldValue:n},s){e._modelValue=t;let o;if(oe(t))o=Go(t,s.props.value)>-1;else if(Rt(t))o=t.has(s.props.value);else{if(t===n)return;o=vt(t,ka(e,!0))}e.checked!==o&&(e.checked=o)}const Xi={created(e,{value:t},n){e.checked=vt(t,n.props.value),e[ot]=en(n),Nt(e,"change",()=>{e[ot](Pn(e))})},beforeUpdate(e,{value:t,oldValue:n},s){e[ot]=en(s),t!==n&&(e.checked=vt(t,s.props.value))}},vn={deep:!0,created(e,{value:t,modifiers:{number:n}},s){e._modelValue=t,Nt(e,"change",()=>{const o=Array.prototype.filter.call(e.options,d=>d.selected).map(d=>n?Ks(Pn(d)):Pn(d)),l=e.multiple,a=l?Rt(e._modelValue)?new Set(o):o:o[0],r=e._pendingValue=[l,l?oe(a)?o.slice():o:a];try{e[ot](a)}finally{Js(()=>{e._pendingValue===r&&(e._pendingValue=void 0)})}}),e[ot]=en(s)},mounted(e,{value:t}){Zi(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[ot]=en(n)},updated(e,{value:t}){const n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!vc(t,n[1],n[0]))&&Zi(e,t)}};function vc(e,t,n){if(!n||oe(e))return vt(e,t);if(Rt(e)){if(e.size!==t.length)return!1;for(const s of t)if(!e.has(s))return!1;return!0}return!1}function Zi(e,t){const n=e.multiple,s=oe(t);if(!(n&&!s&&!Rt(t))){for(let o=0,l=e.options.length;oString(f)===String(r)):a.selected=Go(t,r)>-1}else a.selected=t.has(r);else if(vt(Pn(a),t)){e.selectedIndex!==o&&(e.selectedIndex=o);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Pn(e){return"_value"in e?e._value:e.value}function ka(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const mc=["ctrl","shift","alt","meta"],yc={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>mc.some(n=>e[`${n}Key`]&&!t.includes(n))},dt=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((o,...l)=>{for(let a=0;a{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=(o=>{if(!("key"in o))return;const l=on(o.key);if(t.some(a=>a===l||gc[a]===l))return e(o)}))},bc=Ue({patchProp:cc},Yu);let Qi;function wc(){return Qi||(Qi=Cu(bc))}const _c=((...e)=>{const t=wc().createApp(...e),{mount:n}=t;return t.mount=s=>{const o=$c(s);if(!o)return;const l=t._component;!ue(l)&&!l.render&&!l.template&&(l.template=o.innerHTML),o.nodeType===1&&(o.textContent="");const a=n(o,!1,kc(o));return o instanceof Element&&(o.removeAttribute("v-cloak"),o.setAttribute("data-v-app","")),a},t});function kc(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function $c(e){return Ie(e)?document.querySelector(e):e}const xc=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],Sc=["Su","Mo","Tu","We","Th","Fr","Sa"],xa={clock:"clock-outline",analog:"clock-outline",date:"calendar"},qe=e=>JSON.stringify(e),ke=e=>String.fromCodePoint(parseInt(e,16));function ui(e,t=new Date){let n=t.getHours();return e||(n=n%12||12),`${String(n).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}const ci=(e=new Date)=>`${Sc[e.getDay()]} ${e.getDate()} ${xc[e.getMonth()]}`;function Cc(e,t=Math.floor(Date.now()/1e3)){const n=t-e,s=Math.abs(n),o=l=>Math.floor(s/l);return n<0?s<3600?`In ${Math.max(1,o(60))} min`:s<86400?o(3600)===1?"In 1 hour":`In ${o(3600)} hours`:s<172800?"Tomorrow":`In ${o(86400)} days`:s<60?"Just now":s<3600?`${o(60)} min ago`:s<86400?o(3600)===1?"1 hour ago":`${o(3600)} hours ago`:s<172800?"Yesterday":s<604800?`${o(86400)} days ago`:s<2592e3?o(604800)===1?"1 week ago":`${o(604800)} weeks ago`:s<31536e3?o(2592e3)===1?"1 month ago":`${o(2592e3)} months ago`:o(31536e3)===1?"1 year ago":`${o(31536e3)} years ago`}const Tc={guition:{width:448,top:36,name:27,text:21,icon:26},cyd:{width:298,top:24,name:18,text:14,icon:18}};let Rn;const Fo=new Map,Ac=()=>Fo.clear();function cn(e,t){const n=`${t}|${e}`;let s=Fo.get(n);if(!s){if(Rn===void 0&&(Rn=document.createElement("canvas").getContext("2d")),Rn){Rn.font=t;const o=Rn.measureText(e);s={left:-o.actualBoundingBoxLeft,right:o.actualBoundingBoxRight,top:-o.actualBoundingBoxAscent,bottom:o.actualBoundingBoxDescent,advance:o.width}}else{const o=Number(/(\d+(?:\.\d+)?)px/.exec(t)?.[1]||14),l=[...e].length*o*.55;s={left:0,right:l,top:-o*.72,bottom:o*.02,advance:l}}Fo.set(n,s)}return s}const Ec=e=>({name:`500 ${e.name}px "Bar Roboto"`,text:`400 ${e.text}px "Bar Roboto"`,icon:`${e.icon}px "Tile Icons"`});function Pc(e){return{icon:Math.max(2,Math.floor((e*4+5)/10)),item:Math.max(6,Math.floor((e*125+50)/100)),name:Math.max(8,Math.floor((e*16+5)/10))}}function Sa(e,t,n,s){const o=Ec(t),l=cn("0",o.text),a=Math.round(l.bottom-l.top),r=Pc(a),d=cn(String.fromCodePoint(983376),o.icon),f=Math.round(d.bottom-d.top),c=e.map((C,V)=>{const O=s(C),A={index:V,item:C,view:O,shown:O.shown,width:0};return O.analog?(A.dial=f,A.width=f,A):(O.icon&&(A.icon={glyph:ke(O.icon),ink:cn(ke(O.icon),o.icon)},A.width+=A.icon.ink.right-A.icon.ink.left),O.text&&(A.text={value:O.text,ink:cn(O.text,o.text)},A.width+=(A.icon?r.icon:0)+A.text.ink.right-A.text.ink.left),A)}),y=c.filter(C=>C.shown),b=cn(n,o.name).advance,k=Math.min(b,Math.floor(t.width*35/100)),I=C=>C.reduce((V,O)=>V+O.width,0)+Math.max(0,C.length-1)*r.item;let E=0;for(;Et.width;)E++;const x=y.slice(E),S=new Set(y.slice(0,E).map(C=>C.index));let M=t.width-I(x);for(const C of x)C.x=M,M+=C.width+r.item;const G=x.length?x[0].x-r.name:t.width;return{metrics:t,fonts:o,cap:a,zero:l,gaps:r,parts:c,placed:x,dropped:S,nameText:n,natural:b,nameRoom:G}}function Oc(e,t,n){if(cn(e,t).advance<=n)return e;const s=[...e];for(;s.length&&cn(s.join("")+"...",t).advance>n;)s.pop();return s.join("")+"..."}function Ic(e){"fonts"in document&&Promise.all([document.fonts.load('500 27px "Bar Roboto"',"Studio 0"),document.fonts.load('400 21px "Bar Roboto"',"Away 0"),document.fonts.load('26px "Tile Icons"',String.fromCodePoint(983376))]).then(()=>{Ac(),e()}).catch(()=>{})}let Ca="";function Mc(e){Ca=e||""}async function di(e,t={}){const n=await fetch(`api/${e}`,{...t,headers:{"Content-Type":"application/json","X-Screen-CSRF":Ca,...t.headers}});if(!n.ok){let s="That didn't work. Refresh the page and try again.";try{const o=(await n.json())?.error;typeof o=="string"&&o.trim()&&(s=o)}catch{}throw new Error(s)}return n}const yt=async e=>(await di(e)).json(),it=async(e,t,n,s={})=>{const l=await(await di(e,{method:t,body:n===void 0?void 0:JSON.stringify(n),...s})).text();return l?JSON.parse(l):null},Le=6,Bt=8,os=Bt*Le,Lc=["single","wide","full"],tn=e=>e===!0?"wide":e===!1?"single":e,ps=e=>/^screen\.page_[1-8]$/.test(e)?Number(e.slice(-1)):0,Je=e=>Lc.includes(e.options?.size)?e.options.size:"single",No=e=>Je(e)!=="single",Ta=e=>Je(e)==="full",Aa=e=>e-e%Le,Ea=e=>e-e%2,ht=e=>Math.floor(e/Le),fi=e=>tn(e)==="full"?Le:tn(e)==="wide"?2:1,mn=(e,t)=>tn(t)==="full"?Array.from({length:Le},(n,s)=>Aa(e)+s):tn(t)==="wide"?[e,e+1]:[e],el=(e,t)=>tn(t)==="full"?Aa(e):tn(t)==="wide"?Ea(e):e,Ve=e=>e.tiles.map(t=>({tile:t,slot:t.slot}));function Pa(e){let t=0;return e.map(n=>{const s=Je(n);s==="full"&&t%Le?t+=Le-t%Le:s==="wide"&&t%2===1&&t++;const o=t;return t+=fi(s),o})}function Dc(e){const t=Pa(e);return e.some((n,s)=>n.slot!==t[s])}function to(e){if(e.tiles.some(t=>!Number.isInteger(t.slot))){const t=Pa(e.tiles);e.tiles.forEach((n,s)=>n.slot=t[s])}e.tiles.sort((t,n)=>t.slot-n.slot)}function Tn(e){const t=new Set;for(const{tile:n,slot:s}of e)for(const o of mn(s,Je(n)))t.add(o);return t}const is=(e,t,n)=>Number.isInteger(t)&&t>=0&&t!e.has(s));function Ds(e,t,n=0){for(let s=n;so.includes(f))?r.push(d):a.push({tile:d,slot:d.slot}));for(const d of r){const f=Je(d),c=Tn(a);let y=l.map(b=>el(b,f)).find(b=>is(c,b,f));if(y===void 0&&(y=Oa(c,f,d.slot)),y<0)return null;a.push({tile:d,slot:y})}return a.sort((d,f)=>d.slot-f.slot)}function gt(e,t=1){const n=Math.max(0,...e.map(({tile:s,slot:o})=>o+fi(Je(s))));return Math.min(Bt,Math.max(1,Math.ceil(n/Le),t||1))}function Fc(e){const t=e.split(".")[0];return t==="sun"?{options:{display:"sunpath",size:"wide"}}:t==="weather"?{options:{display:"forecast",size:"wide"}}:ps(e)?{}:t==="screen"?{options:{display:"digital",size:"wide"}}:{}}const Ma=e=>({entity:e,name:"",slot:-1,...Fc(e)});function La(e,t){const n=e.entity.split(".")[0],s=t.controls?.[n],o=e.options||{};if(!s||!["wide","full"].includes(o.size)||(o.display||"standard")!=="standard"||o.inline==="slider")return null;const l=o.controls??(o.size==="full"?"none":s.default);return l==="none"?null:l}function Nc(e,t){const n=La(e,t);return n?t.controls?.[e.entity.split(".")[0]]?.choices.find(s=>s.key===n)?.label.toLocaleLowerCase()||n:"none"}const Ro=e=>(/^(\d+)\.(\d+)\.(\d+)$/.exec(e||"")||[]).slice(1).map(Number);function nn(e,t){const[n,s]=[Ro(e),Ro(t)];if(n.length!==3||s.length!==3)return!1;for(let o=0;o<3;o++)if(n[o]!==s[o])return n[o]>s[o];return!0}const Rc=(e,t,n,s)=>nn(e,`${t}.${n}.${s}`),Hc=os;function Uc(e){return Ro(e).length!==3?10:nn(e,"0.2.62")?Hc:nn(e,"0.2.7")?20:10}const Da={standard:"standard",watch:"large value",forecast:"weather forecast",graph:"graph",digital:"digital clock",analog:"analog clock",sunpath:"sun path"},jc={single:"normal",wide:"double",full:"full page"},tl=["light","switch","input_boolean","fan","media_player","climate"],Bc=["light","fan","cover","number","input_number","media_player"],Vc={light:["Light","☀","#ad7600","#fff3d3"],climate:["Climate","❄","#c86620","#ffebdc"],vacuum:["Vacuum","◉","#008577","#def3ed"],fan:["Fan","✣","#008aab","#def5fa"],cover:["Cover","▤","#8053af","#eee5f8"],media_player:["Media","▶","#007cad","#def2fc"],sensor:["Sensor","⌁","#3476b1","#e5effa"],binary_sensor:["Status","◈","#ad7600","#fff3d3"],switch:["Switch","⏻","#ad7600","#fff3d3"],input_boolean:["Switch","⏻","#ad7600","#fff3d3"],scene:["Scene","✦","#8053af","#eee5f8"],script:["Script","▷","#8053af","#eee5f8"],weather:["Weather","☁","#007cad","#def2fc"],number:["Value","±","#008577","#def3ed"],input_number:["Value","±","#008577","#def3ed"],select:["Select","≡","#5862af","#eaecfa"],input_select:["Select","≡","#5862af","#eaecfa"],button:["Action","↗","#5862af","#eaecfa"],input_button:["Action","↗","#5862af","#eaecfa"],screen:["Screen","◷","#25282c","#e9ecf1"],sun:["Sun","☼","#c86620","#ffebdc"],timer:["Timer","⏱","#008577","#def3ed"],person:["Person","☺","#2f7d32","#e1f2e2"],camera:["Camera","◧","#3d4a57","#e6ebf0"],image:["Image","◧","#3d4a57","#e6ebf0"]},xt=e=>Vc[e.split(".")[0]]||["Entity","◇","#637184","#edf0f4"],u=bn({inventory:{screens:[],entities:[]},connected:!1,reachable:!0,selected:null,layout:null,dirty:!1,busy:!1,saved:0,tab:"layout",selectedTile:null,inspector:null,iconPickerOpen:!1,actionPickerOpen:!1,actionSearch:"",insertAt:-1,filter:"",search:"",capabilities:{},entityActions:{},topbarPreviews:{},topbarAdded:null,topbarOverflow:[],settingEdits:{},settingPending:!1,updating:[],toast:null,now:Date.now(),fontsVersion:0,route:location.hash,overrideProfile:null,overrideFriendly:"",drag:{active:!1,moving:null,preview:null},menuOpen:!1,liveStates:{},room:"",hidePlaced:!1,palette:!1,firmwareJob:null}),lt=$(()=>u.inventory.screens.find(e=>e.id===u.selected)),ls=e=>(e&&"firmware_known"in e?e.firmware_known:e?.firmware)||"",Fa=$(()=>ls(lt.value)),Zt=(e,t,n)=>Rc(Fa.value,e,t,n),yn=$(()=>{const e=lt.value?.tile_limit;return typeof e=="number"&&Number.isInteger(e)&&e>0?e:Uc(Fa.value)}),nl=$(()=>{const e=lt.value?.full_page;return typeof e=="boolean"?e:Zt(0,2,62)}),Wc=$(()=>{const e=lt.value?.page_tiles_repeat;return typeof e=="boolean"?e:Zt(0,2,65)}),no=e=>Wc.value&&ps(e)>0,pi=$(()=>lt.value?.board==="guition"),Na=$(()=>Tc[pi.value?"guition":"cyd"]),go=$(()=>u.selectedTile&&u.layout?.tiles.includes(u.selectedTile)?u.selectedTile:void 0),hi=e=>!!u.selectedTile&&ve(u.selectedTile)===ve(e);let sl=0;function ge(e,t){u.toast={message:e,action:t},clearTimeout(sl),sl=window.setTimeout(()=>u.toast=null,t?8e3:5e3)}function Kc(){u.toast=null}async function ft(e,t,n="API key"){try{if(!navigator.clipboard||!window.isSecureContext)throw new Error;await navigator.clipboard.writeText(e),ge(`${n} copied.`)}catch{if(t){const s=document.createRange();s.selectNodeContents(t);const o=window.getSelection();o?.removeAllRanges(),o?.addRange(s)}ge(document.execCommand("copy")?`${n} copied.`:`${n} is selected. Copy with Ctrl+C or Command+C.`)}}function Fs(){const e="/config/integrations/dashboard";try{window.top.location.assign(e)}catch{window.open(e,"_blank")}}const qc=["","#settings","#new-screen","#firmware","#alerts","#override"],rt=$(()=>qc.includes(u.route)?u.route:"");function Oe(e){if(location.hash===e){u.route=e;return}location.hash=e}window.addEventListener("hashchange",()=>{u.route=location.hash,window.scrollTo(0,0)});function hs(e){return u.inventory.entities.find(t=>t.id===e)?.name||u.inventory.builtin?.find(t=>t.id===e)?.name||e}let bo={source:null,byName:{}};function vs(e){return bo.source!==u.inventory.icons&&(bo={source:u.inventory.icons,byName:Object.fromEntries((u.inventory.icons?.groups||[]).flatMap(t=>t.icons.map(n=>[n.name,n])))}),e?bo.byName[e]:void 0}function sn(e){const t=u.inventory.icons;if(!t)return"F0335";const n=u.inventory.entities.find(o=>o.id===e),s=e.split(".")[0];return t.builtin?.[e]?t.builtin[e]:n?.icon?n.icon:s==="weather"?t.weather[n?.state||""]||t.weather.partlycloudy:s==="sun"?t.sun[n?.state||""]||t.sun.below_horizon:t.defaults[s]||t.fallback}const Bn=e=>vs(e.options?.icon)?.cp||sn(e.entity),wo=new Set;async function so(e){const t=[...new Set(e)].filter(n=>!wo.has(n)&&!n.startsWith("screen."));if(t.length){t.forEach(n=>wo.add(n));try{for(let n=0;n`entity=${encodeURIComponent(o)}`).join("&");Object.assign(u.capabilities,(await yt(`capabilities?${s}`)).capabilities||{})}}catch{t.forEach(n=>wo.delete(n))}}}const _o=new Set;async function Yc(e){if(!_o.has(e)){_o.add(e);try{u.entityActions[e]=(await yt(`entity-actions?entity=${encodeURIComponent(e)}`)).actions}catch{_o.delete(e)}}}let ko=!1;async function oo(){const e=[...new Set((u.layout?.tiles||[]).map(t=>t.entity).filter(t=>!t.startsWith("screen.")))];if(!(!e.length||ko)){ko=!0;try{for(let t=0;t`entity=${encodeURIComponent(s)}`).join("&");Object.assign(u.liveStates,(await yt(`states?${n}`)).states||{})}}catch{}finally{ko=!1}}}function zc(e){const t=u.liveStates[e];if(t)return t;const n=u.inventory.entities.find(s=>s.id===e);return n?.state?{state:n.state,word:null,a:{}}:null}let Ho=0;function Ze(){u.dirty=!0,u.saved=0,Ho++}function Ra(e){if(e===u.selected&&u.layout&&u.dirty){He(),u.tab="layout",u.menuOpen=!1,Oe("");return}if(e!==u.selected&&u.dirty&&!confirm("You have unsaved changes. Open a different screen anyway?"))return;e!==u.selected&&(rs(),u.settingEdits={}),u.selected=e,u.selectedTile=null,u.inspector=null,u.tab="layout",u.menuOpen=!1;const t=u.inventory.screens.find(s=>s.id===e);if(!t){u.layout=null;return}const n=JSON.parse(JSON.stringify(t.layout));to(n),n.pages=gt(Ve(n),n.pages),u.layout=n,u.insertAt=-1,u.dirty=!1,u.saved=0,io(0),so(n.tiles.map(s=>s.entity)),oo(),Oe("")}function Jc(e){const t=u.layout,n=t.tiles.map(o=>`${o.entity}@${o.slot}`).join();for(const{tile:o,slot:l}of e)o.slot=l,t.tiles.includes(o)||t.tiles.push(o);to(t),t.pages=gt(Ve(t),t.pages);const s=t.tiles.map(o=>`${o.entity}@${o.slot}`).join()!==n;return s&&Ze(),s}function ms(e,t){if(!u.layout)return!1;so([e.entity]);const n=Ia(u.layout.tiles,e,t),s=n?Jc(n):!1;return s&&!u.liveStates[e.entity]&&oo(),s}function Ha(e){const t=u.layout;if(!t||!no(e)&&t.tiles.some(o=>o.entity===e)||t.tiles.length>=yn.value)return;const n=Ma(e),s=u.insertAt>=0?u.insertAt:Ds(Tn(Ve(t)),Je(n));u.insertAt=-1,s>=0&&ms(n,s)&&Uo(n)}function Ua(e){const t=u.layout;if(!t)return;const n=t.tiles.indexOf(e);n<0||(t.tiles.splice(n,1),hi(e)&&He(),Ze(),t.pages=gt(Ve(t),t.pages),ge(`${e.name||hs(e.entity)} removed`,{label:"Undo",run:()=>ms(e,e.slot)}))}function ol(){const e=u.layout;e&&(e.pages=Math.min(Bt,gt(Ve(e),e.pages)+1),Ze())}function Gc(e){const t=u.layout;if(t){for(const n of t.tiles)ht(n.slot)>e&&(n.slot-=Le);t.pages=Math.max(1,gt(Ve(t),t.pages)-1),Ze()}}function Xc(e,t){const n=u.layout;if(!n||!Number.isInteger(t)||t<0||t>=Bt||t===ht(e.slot))return!1;const s=Ds(Tn(Ve(n).filter(l=>l.tile!==e)),Je(e),t*Le),o=ms(e,s>=0&&ht(s)===t?s:t*Le);return o||ge(`There is no room for this tile on page ${t+1}.`),o}function Zc(){const e=u.layout;if(!e)return 1;const t=u.drag.preview||Ve(e),n=gt(t,e.pages);return u.drag.active&&nf.key!=="none"&&o.controls.includes(f.key))?.key||"none"),Ze();const d=u.layout;if(d){if(Ta(e)&&a!=="full"){const f=ht(e.slot),c=d.tiles.filter(k=>k!==e&&ht(k.slot)===f),y=Tn(Ve(d).filter(k=>k.tile!==e&&!c.includes(k.tile))),b=[];for(const k of c){const I=Ds(y,Je(k),(f+1)*Le);if(I<0){b.length=0;break}b.push([k,I]);for(const E of mn(I,Je(k)))y.add(E)}if(b.length===c.length){for(const[k,I]of b)k.slot=I;e.slot=f*Le}else{const k=Ds(Tn(Ve(d).filter(I=>I.tile!==e)),"full");k>=0?e.slot=k:(e.options.size=a,ge("No page is free for a full-page tile. Free a page first."))}}else if(No(e)&&!l){const f=Tn(Ve(d).filter(b=>b.tile!==e)),c=Ea(e.slot),y=is(f,c,"wide")?c:Oa(f,"wide",c);y>=0&&(e.slot=y)}to(d),d.pages=gt(Ve(d),d.pages)}}function Qc(e,t){const n=`screen.page_${t}`,s=u.layout;return!s||n===e.entity||!ps(n)?!1:!no(n)&&s.tiles.some(o=>o.entity===n)?(ge(`This screen already has a tile that goes to page ${t}.`),!1):(e.entity=n,s.pages=Math.max(gt(Ve(s),s.pages),t),Ze(),!0)}function Uo(e){hi(e)||(u.iconPickerOpen=!1,u.actionPickerOpen=!1,u.actionSearch=""),u.selectedTile=e,u.inspector={kind:"tile"},so([e.entity])}function Lt(e){u.inspector?.kind==="bar"&&u.inspector.index===e||(u.iconPickerOpen=!1),u.selectedTile=null,u.inspector={kind:"bar",index:e}}function il(){u.selectedTile=null,u.inspector={kind:"bar-add"}}function He(){u.inspector=null,u.selectedTile=null}async function jo(){if(u.busy||!u.layout||!u.selected)return;u.busy=!0;const e=Ho,t=u.selected;try{const{settings:n,...s}=u.layout;await it(`screens/${encodeURIComponent(u.selected)}`,"PUT",s),u.selected!==t?ge(`Saved. ${u.inventory.screens.find(o=>o.id===t)?.name||"The screen"} is being updated.`):Ho===e?(u.dirty=!1,u.saved=Date.now(),ge("Saved. Your screen is being updated.")):ge("Saved. Your newest change isn't sent yet: press Save & send again."),await wn()}catch(n){ge(n.message)}finally{u.busy=!1}}const gn=e=>!!(e&&e.alert_action&&nn(ls(e),u.inventory.alerts?.min_firmware||"0.2.31"));async function ja(e){try{await it(`screens/${encodeURIComponent(e.id)}/identify`,"POST"),ge(`${e.name} blinks and shows a card for a few seconds.`)}catch(t){ge(t.message)}}async function ed(e,t){return await it("alerts/test","POST",{screen:e,data:t})}const td=["title","tiles","header","pages"];function Ba(e,t){const n=u.layout;if(!n)return;const s=(Array.isArray(e.tiles)?e.tiles:[]).filter(r=>!!(r&&typeof r=="object"&&typeof r.entity=="string"&&r.entity.includes("."))).map(r=>({entity:r.entity,name:typeof r.name=="string"?r.name:"",slot:Number.isInteger(r.slot)?r.slot:-1,...r.options&&typeof r.options=="object"?{options:{...r.options}}:{}})),o=new Set,l=s.filter(r=>no(r.entity)||!o.has(r.entity)&&o.add(r.entity)),a=l.slice(0,yn.value);n.tiles=a,e.header&&Array.isArray(e.header.items)?n.header={items:e.header.items.map(r=>({...r}))}:delete n.header,n.pages=Number.isInteger(e.pages)?e.pages:1,to(n),n.pages=gt(Ve(n),n.pages),He(),Ze(),so(a.map(r=>r.entity)),oo(),io(0),ge(a.lengthn.id===e);!t||!u.layout||Ba(JSON.parse(JSON.stringify(t.layout)),`Layout of ${t.name} copied`)}function sd(){const e=u.layout;if(!e)return"";const t={esp_screens_layout:1};for(const n of td)e[n]!==void 0&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Va(){const e=sd();if(!e)return;const t=`${(lt.value?.name||"screen").toLowerCase().replace(/[^a-z0-9]+/g,"-")}.layout.json`,n=URL.createObjectURL(new Blob([e],{type:"application/json"})),s=document.createElement("a");s.href=n,s.download=t,s.click(),setTimeout(()=>URL.revokeObjectURL(n),5e3),ft(e,void 0,"Layout JSON")}function od(e){let t;try{t=JSON.parse(e)}catch{ge("That isn't JSON. Export a layout first, or paste one from another ESP Screens.");return}if(!t||typeof t!="object"||!Array.isArray(t.tiles)){ge("No tiles in this file. Expected a layout exported from ESP Screens.");return}Ba(t,"Layout imported")}function ll(e){const t=u.inventory.updates?.target,n=u.inventory.changelog;if(!Array.isArray(n)||!t)return[];const s=ls(e),o=[];for(const l of n)if(!(nn(l.firmware,t)&&l.firmware!==t)&&!(s&&nn(s,l.firmware)))for(const a of l.lines)o.includes(a)||o.push(a);return o}let $o=!1;async function id(){if(!$o){$o=!0;try{const e=await yt("firmware");u.firmwareJob={job:e.job,logs:e.logs||[]}}catch{}finally{$o=!1}}}const Bo=()=>u.inventory.screens.some(e=>e.update?.state==="running")||u.updating.length>0;function xn(e){const t=e.update||{};if(!(t.state==="running"||u.updating.includes(e.id)))return null;const n=u.firmwareJob?.job?.stage;return t.phase==="verify"?{percent:78,text:Kn.verify}:t.phase==="settle"?{percent:92,text:Kn.settle}:t.phase==="install"||!t.phase?n==="upload"?{percent:66,text:"Writing the firmware over Wi-Fi…"}:n?{percent:40,text:"Building the firmware…"}:{percent:12,text:Kn.install}:{percent:12,text:Kn[t.phase]||"Starting update…"}}const Vt=()=>u.layout?.header?.items??(u.layout?.settings?.show_clock??!0?[{type:"clock"}]:[]),zt=()=>u.inventory.header?.max_items||6;function as(e){u.layout&&(u.layout.header={items:e},Ze(),io())}let al=0;function io(e=150){clearTimeout(al),al=window.setTimeout(async()=>{const t=Vt();if(t.some(n=>n.type==="entity"))try{const n=await it("header-preview","POST",{header:{items:t}});t.forEach((s,o)=>u.topbarPreviews[qe(s)]=n.items[o])}catch{}},e)}function Vn(e){return e.type==="entity"?hs(e.entity):u.inventory.header?.builtin.find(t=>t.type===e.type)?.label||e.type}function qt(e){const t=new Date(u.now);if(e.type==="clock")return{text:ui(On().clock_24h!==!1,t),shown:!0};if(e.type==="date")return{text:ci(t),shown:!0};if(e.type==="analog")return{analog:!0,shown:!0};const n=u.topbarPreviews[qe(e)];return n?{icon:n.i||null,text:n.k==="ago"?Cc(n.e,Math.floor(u.now/1e3)):n.t,color:n.c?`#${n.c}`:null,shown:n.shown}:{icon:e.icon==="none"?null:vs(e.icon)?.cp||sn(e.entity),text:"…",shown:!0,loading:!0}}function xo(e,t){const n=[...Vt()];return t<0||t>=n.length||e===t?!1:(n.splice(t,0,...n.splice(e,1)),as(n),!0)}function rl(e){const t=[...Vt()],[n]=t.splice(e,1);n&&(u.inspector?.kind==="bar"&&He(),as(t),ge(`${Vn(n)} removed from the top bar`,{label:"Undo",run:()=>{const s=[...Vt()];s.splice(Math.min(e,s.length),0,n),as(s)}}))}function So(e){const t=Vt();if(t.length>=zt())return ge(`The top bar has room for ${zt()} items.`);if(t.some(n=>qe(n)===qe(e)))return ge("This is already in the top bar.");u.topbarAdded={key:qe(e),time:Date.now()},as([...t,e]),Lt(t.length)}const ld=[{title:"Brightness",icon:"F0599",rows:[{key:"brightness",label:"Brightness",kind:"number",min:5,max:100,step:5,unit:"%"},{key:"dark_mode",label:"Dark mode",kind:"toggle"},{key:"standby_enabled",label:"Auto standby",kind:"toggle"},{key:"standby_seconds",label:"Standby after",kind:"duration",min:60,max:86400,needs:"standby_enabled"},{key:"standby_brightness",label:"Standby brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"standby_enabled",cap:"brightness"}]},{title:"Night",icon:"F0594",rows:[{key:"night_enabled",label:"Night mode",kind:"toggle"},{key:"night_start",label:"Starts",kind:"moment",needs:"night_enabled"},{key:"night_end",label:"Ends",kind:"moment",needs:"night_enabled"},{key:"night_brightness",label:"Night brightness",kind:"number",min:0,max:100,step:5,unit:"%",needs:"night_enabled",cap:"brightness"}]},{title:"Screen",icon:"F0379",rows:[{key:"clock_24h",label:"Clock",kind:"choice",options:[[!1,"12 hour"],[!0,"24 hour"]]},{key:"auto_home",label:"Back to page 1",kind:"toggle"},{key:"auto_home_seconds",label:"After",kind:"duration",min:30,max:3600,needs:"auto_home"},{key:"home_on_standby",label:"Also on standby",kind:"toggle"},{key:"swipe_pages",label:"Swipe between pages",kind:"toggle"},{key:"rotation",label:"Rotation",kind:"choice",options:[[0,"0°"],[90,"90°"],[180,"180°"],[270,"270°"]]}]}],Wa=4e3;let Jt={},An=null,Ns=0,Ts=null;const vi=()=>lt.value?.settings;function On(){const e=vi(),t={...e?.values||{}};for(const[n,s]of Object.entries(u.settingEdits))t[n]=s.value;return t}const ad=e=>e<300?30:e<900?60:e<3600?300:e<7200?900:1800;function ul(e,t,n,s,o){if(e.kind==="moment"){let r=s&&t%60?Math.floor(t/60)*60+(n>0?60:0):t+n*(s?60:15);return r%=1440,r<0?r+1440:r}const l=e.kind==="duration"?ad(n<0?t-1:t):e.step,a=e.cap?Math.min(e.max,o[e.cap]):e.max;return Math.min(a,Math.max(e.min,t+n*l))}function rd(e){if(e<60)return`${e} sec`;if(e<3600)return`${Math.floor(e/60)} min`;const t=Math.floor(e/3600),n=Math.floor(e%3600/60);return n?`${t} h ${String(n).padStart(2,"0")}`:`${t} h`}function ud(e,t){const n=Math.floor(e/60),s=String(e%60).padStart(2,"0");return t?`${String(n).padStart(2,"0")}:${s}`:`${n%12||12}:${s} ${n<12?"AM":"PM"}`}function cd(e,t){const n=t[e.key];return n==null?"—":e.kind==="number"?`${n}${e.unit||""}`:e.kind==="duration"?rd(n):e.kind==="moment"?ud(n,t.clock_24h!==!1):""}function Wn(e,t,n){if(An&&An!==u.selected&&Object.keys(Jt).length){rs(),ge("Still saving the other screen's settings. Try again in a moment.");return}An=u.selected;const s=On();if(u.settingEdits[e]={value:t,at:Date.now()},Jt[e]=t,e==="brightness")for(const o of["standby_brightness","night_brightness"])s[o]>t&&(u.settingEdits[o]={value:t,at:Date.now()});u.settingPending=!0,clearTimeout(Ns),Ns=window.setTimeout(()=>rs(),n)}async function rs(e=!1){if(clearTimeout(Ns),Ts||!Object.keys(Jt).length||!An)return;const t=An,n=Jt;Jt={};const s=di(`screens/${encodeURIComponent(t)}/settings`,{method:"PUT",body:JSON.stringify({settings:n}),keepalive:e});Ts=s;try{const o=await(await s).json(),l=u.inventory.screens.find(a=>a.id===t);l&&(l.settings=o)}catch(o){if(ge(o.message),t===u.selected)for(const l of Object.keys(n))delete u.settingEdits[l];if(t===u.selected&&n.brightness!==void 0)for(const l of["standby_brightness","night_brightness"])delete u.settingEdits[l]}finally{Ts=null,Object.keys(Jt).length?Ns=window.setTimeout(()=>rs(),150):An=null,u.settingPending=!!Object.keys(Jt).length,t===u.selected&&Rs(),setTimeout(()=>{t===u.selected&&Rs()},Wa+100)}}function Rs(){const e=vi();for(const[t,n]of Object.entries(u.settingEdits))Jt[t]!==void 0||Ts||(e&&e.values[t]===n.value||Date.now()-n.at>Wa)&&delete u.settingEdits[t]}const Kn={install:"Building and installing…",verify:"Waiting for the screen to come back…",settle:"Checking that it stays stable…"};async function Vo(e,t){u.updating.push(e.id);try{await it(`screens/${encodeURIComponent(e.id)}/update`,"POST",t?{host:t}:{}),await wn()}catch(n){u.updating=u.updating.filter(s=>s!==e.id),ge(n.message)}}async function cl(){try{await it("updates/run","POST"),await wn()}catch(e){ge(e.message)}}async function dd(e){try{await it("updates","PUT",{auto:e}),u.inventory.updates&&(u.inventory.updates.auto=e),ge(e?"Screens will now update automatically at night.":"Automatic updates are off.")}catch(t){ge(t.message)}}async function fd(){try{u.inventory.claude_skill=await it("claude-skill","POST"),ge(u.inventory.claude_skill?.restart?"Skill installed. Restart Claude Code once so it finds the new skills folder.":"Skill installed. Claude Code picks it up right away.")}catch(e){ge(e.message)}}async function wn(e=!0){try{const t=await yt(e?"inventory":"inventory?light=1");u.inventory=e?t:{...u.inventory,...t},t.csrf&&Mc(t.csrf),u.connected=!!u.inventory.connected,u.reachable=!0;for(const n of u.inventory.screens)n.update?.state==="running"&&(u.updating=u.updating.filter(s=>s!==n.id));u.selected&&Rs()}catch{u.reachable=!1}}function pd(e){u.inventory={...u.inventory,...e},u.connected=!!u.inventory.connected;for(const t of u.inventory.screens)t.update?.state==="running"&&(u.updating=u.updating.filter(n=>n!==t.id));u.selected&&Rs()}let dl=0,Wo=Date.now(),Hs=!1,Hn=null;function hd(){Hn||typeof EventSource>"u"||(Hn=new EventSource("api/events"),Hn.onopen=()=>{Hs=!0,us()},Hn.onmessage=e=>{document.hidden||pd(JSON.parse(e.data))},Hn.onerror=()=>{Hs=!1,us()})}function us(){clearTimeout(dl);const e=Hs?6e4:u.inventory.updates?.busy?3e3:1e4;dl=window.setTimeout(async()=>{if(!document.hidden){const t=Date.now()-Wo>=3e5;t&&(Wo=Date.now()),(t||!Hs)&&await wn(t)}us()},e)}let fl=!1;function vd(){fl||(fl=!0,wn(),hd(),us(),Ic(()=>u.fontsVersion++),setInterval(()=>{!u.layout||document.hidden||u.drag.active||(u.now=Date.now(),io(0))},3e4),setInterval(()=>{!document.hidden&&u.layout&&u.tab==="layout"&&rt.value===""&&oo()},8e3),setInterval(()=>{!document.hidden&&Bo()?id():u.firmwareJob&&!Bo()&&(u.firmwareJob=null)},3e3),document.addEventListener("visibilitychange",async()=>{document.hidden||(Wo=Date.now(),u.now=Date.now(),await wn(),us())}),window.addEventListener("pagehide",()=>rs(!0)),window.addEventListener("beforeunload",e=>{u.dirty&&(e.preventDefault(),e.returnValue="")}))}const md={class:"side"},yd={id:"screens"},gd=["aria-current","onClick"],bd={class:"txt"},wd={key:0,class:"unsaved",role:"img","aria-label":"Unsaved changes",title:"Unsaved changes"},_d={key:1,class:"pill"},kd={key:2,class:"spin small"},$d=["onSubmit"],xd=["disabled","title","onClick"],Sd={key:2,class:"whatsnew"},Cd=["aria-valuenow"],Td={class:"progress-text"},Ad=["title"],Ed=["title"],Pd={id:"pending"},Od={class:"pending-actions"},Id=["onClick"],Md=["aria-current"],Ld={class:"more"},Dd=["aria-current"],Fd={class:"mdi"},Nd=["aria-current"],Rd={class:"mdi"},Hd=["aria-current"],Ud={class:"mdi"},jd=Ee({__name:"Sidebar",setup(e){const t=pe(null),n=pe("");function s(d){const f=d.update||{};return f.state==="running"||u.updating.includes(d.id)?{kind:"running",text:Kn[f.phase||""]||"Starting update…"}:f.state==="queued"?{kind:"queued",text:"Queued for the update"}:f.available&&d.online?{kind:"available",text:`Update ${f.target}`}:f.result&&Date.now()/1e3-f.result.time<86400?{kind:f.result.state==="failed"?"failed":"done",text:f.result.message}:null}function o(d){const f=d.update||{};f.host&&f.profile?Vo(d):f.profile&&(t.value=d.id,n.value="")}function l(d){const f=n.value.trim();f&&(t.value=null,Vo(d,f))}const a=()=>{const d=u.firmwareJob?.logs||[];return d.length?d[d.length-1]:""},r=d=>d.installed?"Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure.":d.downloaded?"Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure.":`Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${d.file}.`;return(d,f)=>(p(),h("aside",md,[f[20]||(f[20]=i("div",{class:"brand"},[i("span",{class:"mark"},"▦"),i("span",null,"ESP Screens")],-1)),i("span",{id:"connection",class:ie(["conn",{online:m(u).connected}]),role:"status"},v(m(u).reachable?m(u).connected?"Home Assistant connected":"Reconnecting to Home Assistant…":"Management page unreachable · retrying…"),3),i("button",{type:"button",class:"search-btn",id:"open-palette",onClick:f[0]||(f[0]=c=>m(u).palette=!0)},[...f[10]||(f[10]=[Z("⌕ Search",-1),i("kbd",null,"⌘K",-1)])]),f[21]||(f[21]=i("div",{class:"label"},"Screens",-1)),i("div",yd,[(p(!0),h(U,null,le(m(u).inventory.screens,c=>(p(),h("div",{key:c.id,class:ie(["screen-item",{selected:c.id===m(u).selected&&m(rt)===""}])},[i("button",{type:"button",class:"nav-item","aria-current":c.id===m(u).selected&&m(rt)===""?"true":"false",onClick:y=>m(Ra)(c.id)},[i("span",{class:ie(["dot",{off:!c.online}])},null,2),i("span",bd,[i("span",null,v(c.name),1),i("small",null,v(c.online?"Online":"Offline")+v(c.area?" · "+c.area:"")+" · "+v(c.firmware||"unknown"),1)]),c.id===m(u).selected&&m(u).dirty?(p(),h("span",wd)):R("",!0),s(c)?.kind==="available"?(p(),h("span",_d,"Update")):s(c)?.kind==="running"?(p(),h("span",kd)):R("",!0)],8,gd),s(c)?(p(),h("div",{key:0,class:ie(["screen-update",s(c).kind])},[s(c).kind==="available"?(p(),h(U,{key:0},[t.value===c.id?(p(),h(U,{key:0},[f[12]||(f[12]=i("small",null,"The IP address, once; new firmware reports it itself.",-1)),i("form",{class:"screen-host",onSubmit:dt(y=>l(c),["prevent"])},[we(i("input",{"onUpdate:modelValue":f[1]||(f[1]=y=>n.value=y),placeholder:"IP address, e.g. 192.168.1.50",required:"",pattern:"[A-Za-z0-9][A-Za-z0-9.\\-]*","aria-label":"IP address",autofocus:""},null,512),[[Fe,n.value]]),f[11]||(f[11]=i("button",{type:"submit",class:"btn mini primary"},"Start",-1)),i("button",{type:"button",class:"icon-btn","aria-label":"Cancel",onClick:f[2]||(f[2]=y=>t.value=null)},"✕")],40,$d)],64)):(p(),h(U,{key:1},[i("small",null,v(s(c).text),1),i("button",{type:"button",class:"btn mini primary",disabled:!c.update?.profile,title:c.update?.profile?"":"No ESPHome profile found with this device name.",onClick:y=>o(c)},"Update",8,xd)],64)),m(ll)(c).length?(p(),h("details",Sd,[f[13]||(f[13]=i("summary",null,"What's new",-1)),i("ul",null,[(p(!0),h(U,null,le(m(ll)(c).slice(0,8),y=>(p(),h("li",{key:y},v(y),1))),128))])])):R("",!0)],64)):s(c).kind==="running"&&m(xn)(c)?(p(),h(U,{key:1},[i("div",{class:"progress",role:"progressbar","aria-valuenow":m(xn)(c).percent,"aria-valuemin":"0","aria-valuemax":"100"},[i("i",{style:Pe({width:m(xn)(c).percent+"%"})},null,4)],8,Cd),i("div",Td,[i("span",null,v(m(xn)(c).percent)+" %",1),i("span",{title:a()},v(m(xn)(c).text),9,Ad)]),a()?(p(),h("small",{key:0,title:a(),style:{"white-space":"nowrap",overflow:"hidden","text-overflow":"ellipsis"}},v(a()),9,Ed)):R("",!0),i("button",{type:"button",class:"btn link mini",style:{"justify-self":"start"},onClick:f[3]||(f[3]=y=>m(Oe)("#firmware"))},"Full log")],64)):(p(),h("small",{key:2,class:ie({failed:s(c).kind==="failed"})},v(s(c).text),3))],2)):R("",!0)],2))),128))]),i("div",Pd,[(p(!0),h(U,null,le(m(u).inventory.pending||[],c=>(p(),h("div",{key:c.file,class:"pending"},[i("strong",null,v(c.friendly),1),i("small",null,v(r(c)),1),i("div",Od,[i("button",{type:"button",class:"btn mini quiet",onClick:f[4]||(f[4]=(...y)=>m(Fs)&&m(Fs)(...y))},"Open Devices & services"),c.api_key?(p(),h("button",{key:0,type:"button",class:"btn mini quiet",onClick:y=>m(ft)(c.api_key)},"Copy API key",8,Id)):R("",!0)])]))),128))]),i("button",{id:"new-screen",type:"button",class:"nav-item ghost","aria-current":m(rt)==="#new-screen"?"true":"false",onClick:f[5]||(f[5]=c=>m(Oe)("#new-screen"))},[...f[14]||(f[14]=[i("span",{class:"plus"},"+",-1),i("span",{class:"txt"},"New screen",-1)])],8,Md),f[22]||(f[22]=i("div",{class:"spacer"},null,-1)),i("div",Ld,[f[19]||(f[19]=i("div",{class:"label"},"More",-1)),i("button",{id:"open-firmware",type:"button",class:"nav-item","aria-current":m(rt)==="#firmware"?"true":"false",onClick:f[6]||(f[6]=c=>m(Oe)("#firmware"))},[i("span",Fd,v(m(ke)("F0241")),1),f[15]||(f[15]=i("span",{class:"txt"},"Firmware & USB",-1))],8,Dd),i("button",{id:"open-alerts",type:"button",class:"nav-item","aria-current":m(rt)==="#alerts"?"true":"false",onClick:f[7]||(f[7]=c=>m(Oe)("#alerts"))},[i("span",Rd,v(m(ke)("F0594")),1),f[16]||(f[16]=i("span",{class:"txt"},"Alerts",-1))],8,Nd),i("button",{id:"open-settings",type:"button",class:"nav-item","aria-current":m(rt)==="#settings"?"true":"false",onClick:f[8]||(f[8]=c=>m(Oe)("#settings"))},[i("span",Ud,v(m(ke)("F0493")),1),f[17]||(f[17]=i("span",{class:"txt"},"Settings",-1))],8,Hd),i("button",{id:"refresh",type:"button",class:"nav-item ghost",onClick:f[9]||(f[9]=c=>m(wn)())},[...f[18]||(f[18]=[i("span",{class:"plus"},"↻",-1),i("span",{class:"txt"},"Refresh",-1)])])])]))}}),Bd={key:0,id:"toast",class:"toast",role:"status"},Vd=Ee({__name:"Toast",setup(e){return(t,n)=>m(u).toast?(p(),h("div",Bd,[i("span",null,v(m(u).toast.message),1),m(u).toast.action?(p(),h("button",{key:0,type:"button",onClick:n[0]||(n[0]=s=>{m(u).toast.action.run(),m(Kc)()})},v(m(u).toast.action.label),1)):R("",!0)])):R("",!0)}}),Wd={class:"palette-list"},Kd={class:"palette-group"},qd=["onMouseenter","onClick"],Yd={key:0,class:"mdi"},zd={key:1,class:"glyph"},Jd={class:"tx"},Gd={key:0},Xd={key:2,class:"hint-key"},Zd={key:0,class:"palette-empty"},Qd=Ee({__name:"CommandPalette",setup(e){const t=pe(""),n=pe(0),s=pe(null),o=$(()=>{const f=t.value.trim().toLocaleLowerCase(),c=[];for(const k of u.inventory.screens)c.push({group:"Screens",label:k.name,detail:`${k.online?"Online":"Offline"} · ${k.firmware||"unknown"}`,glyphText:"▦",run:()=>Ra(k.id)});const y=lt.value,b=[{group:"Actions",label:"New screen",detail:"Connect and install",glyphText:"+",run:()=>Oe("#new-screen")},{group:"Actions",label:"Firmware & USB",icon:"F0241",run:()=>Oe("#firmware")},{group:"Actions",label:"Alerts",detail:"Cheatsheet and a test alert",icon:"F0594",run:()=>Oe("#alerts")},{group:"Actions",label:"Settings",detail:"Updates, Claude",icon:"F0493",run:()=>Oe("#settings")}];if(y&&u.layout&&b.unshift({group:"Actions",label:"Save & send",detail:u.dirty?"Unsaved changes":"Nothing to save",key:"⌘S",run:()=>jo()},{group:"Actions",label:"Layout",detail:y.name,run:()=>{Oe(""),u.tab="layout"}},{group:"Actions",label:"Screen settings",detail:y.name,run:()=>{Oe(""),u.tab="settings"}},{group:"Actions",label:"Identify this screen",detail:gn(y)?"Blinks the backlight and shows a card":"Needs a newer firmware",run:()=>{gn(y)&&ja(y)}},{group:"Actions",label:"Export layout",detail:"Download and copy as JSON",run:Va}),c.push(...b),y&&u.layout&&f){const k=new Set(u.layout.tiles.map(E=>E.entity)),I=u.layout.tiles.length>=yn.value;for(const E of u.inventory.entities)if(!(E.tile===!1||k.has(E.id))&&`${E.name} ${E.id} ${E.area||""} ${E.device||""}`.toLocaleLowerCase().includes(f)&&(c.push({group:"Add to this screen",label:E.name,detail:[xt(E.id)[0],E.area].filter(Boolean).join(" · "),icon:u.inventory.icons?sn(E.id):void 0,run:()=>{I||Ha(E.id)}}),c.length>60))break}return f?c.filter(k=>`${k.label} ${k.detail||""}`.toLocaleLowerCase().includes(f)):c}),l=$(()=>{const f=[];return o.value.forEach((c,y)=>{(f.find(k=>k.group===c.group)||(f.push({group:c.group,items:[]}),f[f.length-1])).items.push({item:c,index:y})}),f});function a(){u.palette=!1}function r(f){a(),f.run()}function d(f){if(f.key==="Escape")f.preventDefault(),a();else if(f.key==="ArrowDown")f.preventDefault(),n.value=Math.min(o.value.length-1,n.value+1);else if(f.key==="ArrowUp")f.preventDefault(),n.value=Math.max(0,n.value-1);else if(f.key==="Enter"){f.preventDefault();const c=o.value[n.value];c&&r(c)}}return pn(t,()=>n.value=0),pn(()=>u.palette,async f=>{f&&(t.value="",n.value=0,await Js(),s.value?.focus())}),(f,c)=>m(u).palette?(p(),h("div",{key:0,class:"palette-backdrop",onClick:a},[i("div",{class:"palette",role:"dialog","aria-label":"Search",onClick:c[1]||(c[1]=dt(()=>{},["stop"])),onKeydown:d},[we(i("input",{ref_key:"input",ref:s,"onUpdate:modelValue":c[0]||(c[0]=y=>t.value=y),id:"palette-input",placeholder:"Search screens, entities, actions…","aria-label":"Search",autocomplete:"off"},null,512),[[Fe,t.value]]),i("div",Wd,[(p(!0),h(U,null,le(l.value,y=>(p(),h(U,{key:y.group},[i("div",Kd,v(y.group),1),(p(!0),h(U,null,le(y.items,({item:b,index:k})=>(p(),h("button",{key:k,type:"button",class:ie(["palette-item",{active:k===n.value}]),onMouseenter:I=>n.value=k,onClick:I=>r(b)},[b.icon?(p(),h("span",Yd,v(m(ke)(b.icon)),1)):(p(),h("span",zd,v(b.glyphText||"›"),1)),i("span",Jd,[i("span",null,v(b.label),1),b.detail?(p(),h("small",Gd,v(b.detail),1)):R("",!0)]),b.key?(p(),h("kbd",Xd,v(b.key),1)):R("",!0)],42,qd))),128))],64))),128)),o.value.length?R("",!0):(p(),h("p",Zd,"Nothing matches. Type part of a screen, entity or action."))])],32)])):R("",!0)}}),re={source:null,element:null,ghost:null,timer:0,start:null,offset:{x:0,y:0},pointerId:null,suppressUntil:0,last:null,scroller:0,target:null},Ka={mounted(e,t){e.__dragSource=t.value,e.addEventListener("pointerdown",s=>{const o=e.__dragSource;if(!(s.button!==0||e.disabled||s.target.closest(".remove"))){s.pointerType!=="touch"&&s.preventDefault(),Object.assign(re,{source:o,element:e,start:{x:s.clientX,y:s.clientY},pointerId:s.pointerId});try{e.setPointerCapture(s.pointerId)}catch{}clearTimeout(re.timer),s.pointerType==="touch"&&(re.timer=window.setTimeout(()=>pl(s),260))}}),e.addEventListener("pointermove",s=>{if(u.drag.active||!re.start||re.element!==e)return;const o=Math.hypot(s.clientX-re.start.x,s.clientY-re.start.y);if(s.pointerType==="touch"){o>10&&(clearTimeout(re.timer),re.start=null);return}o>=6&&pl(s)});const n=()=>{re.element===e&&!u.drag.active&&(clearTimeout(re.timer),re.start=null)};e.addEventListener("pointerup",n),e.addEventListener("pointercancel",n)},updated(e,t){e.__dragSource=t.value}};function pl(e){if(u.drag.active||!re.start||!re.source||!re.element)return;u.drag.active=!0,u.drag.moving=re.source.kind==="tile"?re.source.tile:Ma(re.source.id),u.drag.preview=null,re.target=null,getSelection()?.removeAllRanges();const t=re.element.getBoundingClientRect(),n=re.element.cloneNode(!0);n.classList.add("drag-ghost"),n.style.width=`${t.width}px`,n.style.height=`${t.height}px`,re.offset={x:e.clientX-t.left,y:e.clientY-t.top},document.body.append(n),re.ghost=n,document.addEventListener("pointermove",Ko),document.addEventListener("pointerup",Us),document.addEventListener("pointercancel",Us);try{re.element.releasePointerCapture(re.pointerId)}catch{}try{document.documentElement.setPointerCapture(re.pointerId)}catch{}document.addEventListener("touchmove",qa,{passive:!1}),re.scroller=window.setInterval(()=>{if(!re.last)return;const{x:s,y:o}=ef(document),l=s?ml(re.last.x,vl(s,"x")):0,a=o?ml(re.last.y,vl(o,"y")):0;l&&s.scrollBy(l,0),a&&o.scrollBy(0,a),(l||a)&&qo(Ya(re.last.x,re.last.y))},16),qo(-1),Ko(e)}function hl(e,t){if(!e)return!1;const n=t==="x"?e.scrollWidth>e.clientWidth:e.scrollHeight>e.clientHeight,s=getComputedStyle(e)[t==="x"?"overflowX":"overflowY"];return n&&(s==="auto"||s==="scroll")}function ef(e){const t=e.querySelector(".pages"),n=e.querySelector(".canvas");return{x:hl(t,"x")?t:n,y:hl(n,"y")?n:e.scrollingElement}}function vl(e,t){const n=t==="x"?window.innerWidth:window.innerHeight;if(e===e.ownerDocument.scrollingElement)return[0,n];const s=e.getBoundingClientRect();return t==="x"?[Math.max(s.left,0),Math.min(s.right,n)]:[Math.max(s.top,0),Math.min(s.bottom,n)]}function ml(e,[t,n]){return en-60?12:0}function qa(e){u.drag.active&&e.preventDefault()}function Us(e){e.pointerId===re.pointerId&&tf(e.type==="pointerup")}function Ko(e){!re.ghost||e.pointerId!==re.pointerId||(re.last={x:e.clientX,y:e.clientY},re.ghost.style.transform=`translate(${e.clientX-re.offset.x}px, ${e.clientY-re.offset.y}px)`,qo(Ya(e.clientX,e.clientY)))}function Ya(e,t){let n=null,s=1/0;for(const l of document.querySelectorAll(".pages [data-slot]")){const a=l.getBoundingClientRect(),r=Math.hypot(Math.max(a.left-e,0,e-a.right),Math.max(a.top-t,0,t-a.bottom));r16)return-1;let o=Number(n.cell.dataset.slot);return n.cell.classList.contains("wide")&&e>(n.r.left+n.r.right)/2&&(o+=1),o}function qo(e){re.target===e||!u.layout||!u.drag.moving||(re.target=e,u.drag.preview=e>=0?Ia(u.layout.tiles,u.drag.moving,e):null)}function tf(e){const t=u.drag.preview,n=u.drag.moving;document.removeEventListener("pointermove",Ko),document.removeEventListener("pointerup",Us),document.removeEventListener("pointercancel",Us),document.removeEventListener("touchmove",qa);try{document.documentElement.releasePointerCapture(re.pointerId)}catch{}if(re.ghost?.remove(),Object.assign(re,{ghost:null,suppressUntil:Date.now()+400,last:null,start:null,target:null,element:null,source:null}),clearInterval(re.scroller),u.drag.active=!1,u.drag.preview=null,u.drag.moving=null,e&&t&&n&&u.layout){const s=u.layout.tiles.map(l=>`${l.entity}@${l.slot}`).join(),o=!u.layout.tiles.includes(n);for(const{tile:l,slot:a}of t)l.slot=a,u.layout.tiles.includes(l)||u.layout.tiles.push(l);u.layout.tiles.sort((l,a)=>l.slot-a.slot),u.layout.tiles.map(l=>`${l.entity}@${l.slot}`).join()!==s&&Ze(),o&&ms(n,n.slot)}}window.addEventListener("click",e=>{Date.now()t.tile.name||hs(t.tile.entity)),s=$(()=>Ta(t.tile)),o=$(()=>No(t.tile)&&!s.value),l=$(()=>ps(t.tile.entity)),a=$(()=>u.inventory.backgrounds?.[t.tile.options?.background||""]?.color),r=$(()=>t.tile.options?.background==="none"),d=$(()=>t.tile.options?.display||"standard"),f=$(()=>d.value!=="standard"?Da[d.value]||d.value:""),c=$(()=>La(t.tile,u.inventory)),y=$(()=>t.tile.entity.split(".")[0]),b=$(()=>u.inventory.icons?.controls||{}),k=Q=>b.value[Q]?ke(b.value[Q]):"",I=$(()=>hi(t.tile)&&u.inspector?.kind==="tile"),E=$(()=>!t.placeholder&&u.layout?.tiles.includes(t.tile)),x=$(()=>`${n.value}, slot ${t.slot%Le+1} on page ${ht(t.slot)+1}. Enter: configure, arrow keys: move`),S=$(()=>new Date(u.now)),M=$(()=>(S.value.getHours()%12+S.value.getMinutes()/60)*30),G=$(()=>S.value.getMinutes()*6),C=$(()=>y.value==="screen"?null:zc(t.tile.entity)),V=$(()=>!C.value||["unavailable","unknown",""].includes(C.value.state)),O=$(()=>!!C.value&&!V.value&&C.value.state!=="off"&&C.value.state!=="closed"&&C.value.state!=="standby"&&C.value.state!=="idle"&&C.value.state!=="docked"),A=$(()=>["light","switch","input_boolean","fan"].includes(y.value)&&C.value?.state==="on"),w=$(()=>C.value?.a?.unit_of_measurement),T=Q=>Q.charAt(0).toUpperCase()+Q.slice(1).replace(/_/g," "),te={"clear-night":"Clear, night",cloudy:"Cloudy",exceptional:"Exceptional",fog:"Fog",hail:"Hail",lightning:"Lightning","lightning-rainy":"Lightning, rainy",partlycloudy:"Partly cloudy",pouring:"Pouring",rainy:"Rainy",snowy:"Snowy","snowy-rainy":"Snowy, rainy",sunny:"Sunny",windy:"Windy","windy-variant":"Windy"},$e=["scene","script","button","input_button"],Me=$(()=>{const Q=C.value;if(!Q||$e.includes(y.value))return f.value;if(V.value)return Q.state==="unknown"?"Unknown":"Unavailable";const K=Q.a||{};return y.value==="climate"?`${K.current_temperature!==void 0?`${K.current_temperature}° · `:""}${Q.word||T(Q.state)}`:y.value==="weather"?`${Q.word||te[Q.state]||T(Q.state)}${K.temperature!==void 0?` · ${K.temperature}°`:""}`:y.value==="cover"&&K.current_position!==void 0&&K.current_position>0&&K.current_position<100?`${Q.word||T(Q.state)} · ${K.current_position} %`:y.value==="media_player"&&K.media_title?`${Q.word||T(Q.state)} · ${K.media_title}`:y.value==="sensor"?`${Q.state}${w.value?` ${w.value}`:""}`:(y.value==="timer",Q.word||T(Q.state))}),X=$(()=>C.value&&!V.value?C.value.state:"—"),j=$(()=>{const Q=C.value;if(!Q||V.value)return 0;const K=Q.a||{};if(y.value==="light")return Q.state==="on"?K.brightness!==void 0?Math.round(K.brightness/255*100):100:0;if(y.value==="fan")return Q.state==="on"?K.percentage??100:0;if(y.value==="cover")return K.current_position??(Q.state==="open"?100:0);if(y.value==="media_player")return Math.round((K.volume_level??0)*100);if(y.value==="number"||y.value==="input_number"){const B=Number(Q.state),ne=Number(K.min??0),Qe=Number(K.max??100);return Number.isFinite(B)&&Qe>ne?Math.round((B-ne)/(Qe-ne)*100):0}return 0}),z=$(()=>({background:`linear-gradient(to right, ${j.value?"#ffbf38":"#c9ccd1"} ${j.value}%, ${j.value?"#fff1d3":"#e6e8ec"} ${j.value}%)`})),ce=$(()=>({background:`linear-gradient(to right, #2196f3 ${j.value}%, #d3e8fb ${j.value}%)`})),de=$(()=>{const Q=C.value?.a?.temperature;return Q!=null?`${Q}°`:"—"});async function he(Q){if(Q.key==="Enter"||Q.key===" "){Q.preventDefault(),Uo(t.tile);return}const K={ArrowLeft:-1,ArrowRight:1,ArrowUp:-2,ArrowDown:2}[Q.key];K&&(Q.preventDefault(),ms(t.tile,t.tile.slot+(s.value?Math.sign(K)*Le:o.value?Math.sign(K)*2:K))&&(await Js(),document.querySelector(`.pages [data-slot="${t.tile.slot}"]`)?.focus()))}return(Q,K)=>we((p(),h("div",{class:ie(["tile",{wide:o.value,full:s.value,bare:r.value,placeholder:e.placeholder||!E.value,chosen:I.value}]),"data-slot":e.slot,style:Pe(a.value&&!r.value?{backgroundColor:a.value}:void 0),tabindex:E.value?0:-1,role:E.value?"button":void 0,"aria-label":E.value?x.value:void 0,onClick:K[1]||(K[1]=B=>E.value&&m(Uo)(e.tile)),onKeydown:K[2]||(K[2]=B=>E.value&&he(B))},[d.value==="analog"?(p(),h(U,{key:0},[(p(),h("svg",sf,[K[3]||(K[3]=i("circle",{cx:"30",cy:"30",r:"27",fill:"#fff",stroke:"#c9ccd1"},null,-1)),(p(),h(U,null,le([0,90,180,270],B=>i("line",{key:B,x1:"30",y1:"5",x2:"30",y2:"9",stroke:"#1b1b1b","stroke-width":"1.5",transform:`rotate(${B} 30 30)`},null,8,of)),64)),i("line",{x1:"30",y1:"30",x2:"30",y2:"16",stroke:"#1b1b1b","stroke-width":"2.4","stroke-linecap":"round",transform:`rotate(${M.value} 30 30)`},null,8,lf),i("line",{x1:"30",y1:"30",x2:"30",y2:"11",stroke:"#1b1b1b","stroke-width":"1.6","stroke-linecap":"round",transform:`rotate(${G.value} 30 30)`},null,8,af),K[4]||(K[4]=i("circle",{cx:"30",cy:"30",r:"1.8",fill:"#1b1b1b"},null,-1))])),o.value?(p(),h("span",rf,[i("span",uf,[i("span",cf,v(n.value),1),i("span",df,v(f.value),1)])])):R("",!0)],64)):d.value==="digital"&&y.value==="screen"?(p(),h(U,{key:1},[i("span",ff,v(m(ke)(m(Bn)(e.tile))),1),i("span",pf,[i("span",hf,v(String(S.value.getHours()).padStart(2,"0"))+":"+v(String(S.value.getMinutes()).padStart(2,"0")),1),i("span",vf,v(n.value),1)])],64)):s.value?(p(),h(U,{key:2},[i("span",{class:ie(["ic mdi",{lit:A.value}])},v(m(ke)(m(Bn)(e.tile))),3),i("span",mf,[i("span",yf,v(n.value),1),l.value?(p(),h("span",gf,"Page "+v(l.value)+" ›",1)):d.value==="watch"?(p(),h("span",bf,[Z(v(X.value),1),w.value&&!V.value?(p(),h("small",wf,v(w.value),1)):R("",!0)])):Me.value?(p(),h("span",{key:2,class:ie(["st",{off:V.value}])},v(Me.value),3)):R("",!0)]),e.tile.options?.inline==="slider"?(p(),h("span",{key:0,class:"mini-slider",style:Pe(z.value)},null,4)):R("",!0),c.value?(p(),h("span",_f,[c.value==="toggle"?(p(),h("span",{key:0,class:ie(["tog",{off:!O.value}])},null,2)):c.value==="setpoint"?(p(),h("span",kf,[i("span",$f,v(k("minus")||"−"),1),i("b",null,v(de.value),1),i("span",xf,v(k("plus")||"+"),1)])):c.value==="volume"?(p(),h(U,{key:2},[i("span",{class:"range",style:Pe(ce.value)},null,4),i("span",Sf,v(k("volume-high")),1)],64)):c.value==="playback"?(p(),h(U,{key:3},[i("span",Cf,v(k("skip-previous")),1),i("span",Tf,v(k(O.value?"pause":"play")||k("play")),1),i("span",Af,v(k("skip-next")),1)],64)):c.value==="buttons"&&y.value==="cover"?(p(),h(U,{key:4},[i("span",Ef,v(k("arrow-expand-horizontal")),1),i("span",Pf,v(k("stop")),1),i("span",Of,v(k("arrow-collapse-horizontal")),1)],64)):c.value==="run"?(p(),h("span",If,v({scene:"Activate",script:"Run"}[y.value]||"Press"),1)):(p(),h("span",{key:6,class:"range",style:Pe({background:`linear-gradient(to right, #2196f3 ${j.value}%, #d3e8fb ${j.value}%)`})},null,4))])):R("",!0)],64)):o.value?(p(),h(U,{key:3},[i("span",Mf,[i("span",{class:ie(["ic mdi",{lit:A.value}])},v(m(ke)(m(Bn)(e.tile))),3),i("span",Lf,[i("span",Df,v(n.value),1),l.value?(p(),h("span",Ff,"Page "+v(l.value)+" ›",1)):d.value==="watch"?(p(),h("span",Nf,[Z(v(X.value),1),w.value&&!V.value?(p(),h("small",Rf,v(w.value),1)):R("",!0)])):Me.value?(p(),h("span",{key:2,class:ie(["st",{off:V.value}])},v(Me.value),3)):R("",!0)])]),e.tile.options?.inline==="slider"?(p(),h("span",{key:0,class:"mini-slider",style:Pe(z.value)},null,4)):R("",!0),c.value?(p(),h("span",Hf,[c.value==="toggle"?(p(),h("span",{key:0,class:ie(["tog",{off:!O.value}])},null,2)):c.value==="setpoint"?(p(),h("span",Uf,[i("span",jf,v(k("minus")||"−"),1),i("b",null,v(de.value),1),i("span",Bf,v(k("plus")||"+"),1)])):c.value==="stepper"&&y.value.endsWith("select")?(p(),h(U,{key:2},[i("span",Vf,v(k("chevron-left")),1),i("span",Wf,v(k("chevron-right")),1)],64)):c.value==="stepper"?(p(),h("span",Kf,[i("span",qf,v(k("minus")||"−"),1),i("b",null,v(X.value),1),i("span",Yf,v(k("plus")||"+"),1)])):c.value==="mode"?(p(),h(U,{key:4},[i("span",zf,v(k("power")),1),i("span",Jf,v(k("fire")),1),i("span",Gf,v(k("snowflake")),1)],64)):c.value==="volume"?(p(),h(U,{key:5},[i("span",{class:"range",style:Pe(ce.value)},null,4),i("span",Xf,v(k("volume-high")),1)],64)):c.value==="playback"?(p(),h(U,{key:6},[i("span",Zf,v(k("skip-previous")),1),i("span",Qf,v(k(O.value?"pause":"play")||k("play")),1),i("span",ep,v(k("skip-next")),1)],64)):c.value==="buttons"&&y.value==="cover"?(p(),h(U,{key:7},[i("span",tp,v(k("arrow-expand-horizontal")),1),i("span",np,v(k("stop")),1),i("span",sp,v(k("arrow-collapse-horizontal")),1)],64)):c.value==="buttons"&&y.value==="vacuum"?(p(),h(U,{key:8},[i("span",op,v(k("play")),1),i("span",ip,v(k("stop")),1),i("span",lp,v(k("home-map-marker")),1)],64)):c.value==="buttons"&&y.value==="timer"?(p(),h(U,{key:9},[i("span",ap,v(k("play")),1),i("span",rp,v(k("close")),1)],64)):c.value==="run"?(p(),h("span",up,v({scene:"Activate",script:"Run"}[y.value]||"Press"),1)):(p(),h("span",{key:11,class:"range",style:Pe({background:`linear-gradient(to right, #2196f3 ${j.value}%, #d3e8fb ${j.value}%)`})},null,4))])):R("",!0)],64)):(p(),h(U,{key:4},[i("span",{class:ie(["head",{top:d.value==="watch"}])},[i("span",{class:ie(["ic mdi",{lit:A.value}])},v(m(ke)(m(Bn)(e.tile))),3),i("span",cp,[i("span",dp,v(n.value),1),l.value?(p(),h("span",fp,"Page "+v(l.value)+" ›",1)):d.value!=="watch"&&Me.value?(p(),h("span",{key:1,class:ie(["st",{off:V.value}])},v(Me.value),3)):R("",!0)])],2),d.value==="watch"?(p(),h("span",pp,[Z(v(X.value),1),w.value&&!V.value?(p(),h("small",hp,v(w.value),1)):R("",!0)])):R("",!0),e.tile.options?.inline==="slider"?(p(),h("span",{key:1,class:"mini-slider",style:Pe(z.value)},null,4)):R("",!0)],64)),E.value?(p(),h("button",{key:5,type:"button",class:"remove",title:"Remove tile","aria-label":`Remove ${n.value}`,onClick:K[0]||(K[0]=dt(B=>m(Ua)(e.tile),["stop"]))},"✕",8,vp)):R("",!0)],46,nf)),[[m(Ka),{kind:"tile",tile:e.tile}]])}}),yp=["viewBox","aria-label"],gp=["y"],bp=["cx","cy","r","stroke","stroke-width"],wp=["x1","y1","x2","y2","stroke","stroke-width"],_p=["x","y","fill"],kp=["x","y"],za=Ee({__name:"TopbarSvg",props:{items:{},nameText:{},single:{type:Boolean}},setup(e,{expose:t}){const n=e,s=$(()=>{u.fontsVersion,u.now,u.topbarPreviews;const x=n.items||Vt();return Sa(x,Na.value,n.nameText??(u.layout?.title||"Home"),qt)}),o=$(()=>s.value.metrics),l=$(()=>n.single?Math.round(o.value.text*1.4):o.value.top+Math.round(o.value.name*.45)),a=$(()=>n.single?Math.round(o.value.text*1.05):o.value.top),r=$(()=>{if(!n.single)return s.value.placed;const x=s.value.parts[0];return x&&(x.x=0),x?[x]:[]}),d=$(()=>n.single?`-2 0 ${Math.max(1,r.value[0]?.width||1)+4} ${l.value}`:`0 0 ${o.value.width} ${l.value}`),f=$(()=>a.value+(s.value.zero.top+s.value.zero.bottom)/2),c=$(()=>Oc(s.value.nameText,s.value.fonts.name,Math.min(s.value.natural,s.value.nameRoom))),y=$(()=>new Date(u.now));function b(x){const S=y.value;return[[(S.getHours()%12+S.getMinutes()/60)*30,x*.24],[S.getMinutes()*6,x*.34]].map(([M,G])=>{const C=M*Math.PI/180;return{x2:G*Math.sin(C),y2:-G*Math.cos(C)}})}const k=x=>x.x-x.icon.ink.left,I=x=>(x.icon?x.x+(x.icon.ink.right-x.icon.ink.left)+s.value.gaps.icon:x.x)-x.text.ink.left,E=$(()=>`${(Math.max(1,r.value[0]?.width||1)+4)*(26/o.value.icon)}px`);return t({lay:s}),(x,S)=>(p(),h("svg",{viewBox:d.value,role:"img","aria-label":e.single?"How it looks on the screen":`Top bar: ${s.value.nameText}`,style:Pe(e.single?{width:E.value}:void 0)},[e.single?R("",!0):(p(),h("text",{key:0,x:"0",y:o.value.top,fill:"#1b1b1b",style:Pe({font:s.value.fonts.name})},v(c.value),13,gp)),i("g",null,[(p(!0),h(U,null,le(r.value,M=>(p(),h(U,{key:M.index},[M.dial?(p(),h(U,{key:0},[i("circle",{cx:M.x+M.dial/2,cy:f.value,r:M.dial/2-Math.max(1,Math.round(M.dial/10))/2,fill:"none",stroke:M.view.color||"#46525e","stroke-width":Math.max(1,Math.round(M.dial/10))},null,8,bp),(p(!0),h(U,null,le(b(M.dial),(G,C)=>(p(),h("line",{key:C,x1:M.x+M.dial/2,y1:f.value,x2:M.x+M.dial/2+G.x2,y2:f.value+G.y2,stroke:M.view.color||"#46525e","stroke-width":Math.max(1,Math.round(M.dial*.075)),"stroke-linecap":"round"},null,8,wp))),128))],64)):(p(),h(U,{key:1},[M.icon?(p(),h("text",{key:0,x:k(M),y:f.value-(M.icon.ink.top+M.icon.ink.bottom)/2,fill:M.view.color||"#46525e",style:Pe({font:s.value.fonts.icon})},v(M.icon.glyph),13,_p)):R("",!0),M.text?(p(),h("text",{key:1,x:I(M),y:a.value,fill:"#46525e",style:Pe({font:s.value.fonts.text})},v(M.text.value),13,kp)):R("",!0)],64))],64))),128))])],12,yp))}}),$p={class:"page"},xp={class:"page-label"},Sp={key:1},Cp={class:"tiles"},Tp=["data-slot","aria-label","onClick"],Ap=Ee({__name:"DevicePage",props:{page:{},entries:{},pages:{},moving:{}},setup(e){const t=e,n=$(()=>new Map(t.entries.map(f=>[f.slot,f]))),s=$(()=>new Set(t.entries.flatMap(f=>mn(f.slot,Je(f.tile)).slice(1)))),o=$(()=>Array.from({length:Le},(f,c)=>t.page*Le+c).filter(f=>!s.value.has(f))),l=$(()=>t.pages>1&&!t.entries.some(f=>ht(f.slot)===t.page)),a=$(()=>u.inspector?.kind==="bar"||u.inspector?.kind==="bar-add"),r=$(()=>t.entries.filter(f=>ht(f.slot)===t.page).reduce((f,c)=>f+fi(Je(c.tile)),0));function d(f){const c=u.insertAt===f;u.insertAt=c?-1:f,u.insertAt>=0&&document.querySelector("#search")?.focus()}return(f,c)=>(p(),h("div",$p,[i("div",xp,[i("span",null,"Page "+v(e.page+1),1),l.value?(p(),h("button",{key:0,type:"button",class:"btn mini",title:"The pages after this one shift up one slot",onClick:c[0]||(c[0]=y=>m(Gc)(e.page))},"Remove page")):(p(),h("span",Sp,v(r.value)+" / "+v(m(Le)),1))]),i("div",{class:ie(["device",{cyd:!m(pi)}])},[i("div",{class:ie(["bar-wrap",{selected:a.value}]),title:"Edit top bar",role:"button",tabindex:"0",onClick:c[1]||(c[1]=y=>m(Lt)(0)),onKeydown:c[2]||(c[2]=$a(dt(y=>m(Lt)(0),["prevent"]),["enter"]))},[_e(za)],34),i("div",Cp,[(p(!0),h(U,null,le(o.value,y=>(p(),h(U,{key:y},[n.value.get(y)?(p(),Xe(mp,{key:0,tile:n.value.get(y).tile,slot:y,placeholder:n.value.get(y).tile===e.moving},null,8,["tile","slot","placeholder"])):(p(),h("button",{key:1,type:"button",class:ie(["cell",{"insert-here":m(u).insertAt===y}]),"data-slot":y,title:"Empty slot. Click to add a tile here, or drag one over.","aria-label":`Empty slot ${y%m(Le)+1} on page ${e.page+1}: add the next tile here`,onClick:b=>d(y)},[c[3]||(c[3]=i("span",null,"+",-1)),i("small",null,v(m(u).insertAt===y?"Next tile goes here":"Empty"),1)],10,Tp))],64))),128))])],2)]))}}),Ep={class:"library",id:"library"},Pp={class:"lib-head"},Op={class:"lib-title"},Ip={class:"filters",id:"filters"},Mp=["aria-pressed","onClick"],Lp={class:"lib-row"},Dp=["value"],Fp=["aria-pressed"],Np={class:"lib-list",id:"results","aria-live":"polite"},Rp=["title","disabled","onClick"],Hp={class:"tx"},Up={key:0,class:"hint"},jp={key:1,class:"hint"},Bp={class:"lib-foot"},Vp=Ee({__name:"Library",setup(e){const t=[["","All"],["light","Lights"],["climate","Climate"],["switch","Switches"],["binary_sensor","Status"],["button","Actions"],["script","Scripts"],["fan","Fans"],["cover","Covers"],["scene","Scenes"],["vacuum","Vacuum"],["sensor","Sensors"],["media_player","Media"],["weather","Weather"],["number","Values"],["select","Selects"],["person","People"],["timer","Timers"],["screen","Screen"]],n={switch:"input_boolean",number:"input_number",select:"input_select",weather:"sun",button:"input_button"},s=$(()=>new Set(u.layout?.tiles.map(c=>c.entity)||[])),o=c=>s.value.has(c)&&!no(c),l=$(()=>[...new Set(u.inventory.entities.map(c=>c.area).filter(c=>!!c))].sort((c,y)=>c.localeCompare(y))),a=$(()=>{const c=u.search.toLocaleLowerCase(),y=u.filter,b=u.room;return[...u.inventory.builtin||[],...u.inventory.entities].filter(k=>k.tile!==!1&&(pi.value||!["camera","image"].includes(k.id.split(".")[0]))&&(!y||k.id.startsWith(y+".")||n[y]===k.id.split(".")[0])&&(!b||k.area===b)&&(!u.hidePlaced||!o(k.id))&&`${k.name} ${k.id} ${k.device||""} ${k.area||""}`.toLocaleLowerCase().includes(c))}),r=$(()=>(u.layout?.tiles.length||0)>=yn.value),d=$(()=>u.inventory.entities.length),f=c=>{const y=c.id.split(".")[0];return c.state==="unavailable"||c.state==="unknown"?"gone":["light","switch","input_boolean","fan"].includes(y)&&c.state==="on"?"on":""};return(c,y)=>(p(),h("aside",Ep,[i("div",Pp,[i("div",Op,[y[3]||(y[3]=Z("Library ",-1)),i("small",null,v(d.value)+" entities",1)]),we(i("input",{id:"search","onUpdate:modelValue":y[0]||(y[0]=b=>m(u).search=b),type:"search",placeholder:"Search by name, device, or room…",autocomplete:"off","aria-label":"Add an entity"},null,512),[[Fe,m(u).search]]),i("div",Ip,[(p(),h(U,null,le(t,([b,k])=>i("button",{key:b,type:"button","aria-pressed":m(u).filter===b?"true":"false",onClick:I=>m(u).filter=b},[b?(p(),h("span",{key:0,class:"domain-icon",style:Pe({color:m(xt)(b+".")[2],background:m(xt)(b+".")[3]}),"aria-hidden":"true"},v(m(xt)(b+".")[1]),5)):R("",!0),Z(v(k),1)],8,Mp)),64))]),i("div",Lp,[we(i("select",{id:"room","onUpdate:modelValue":y[1]||(y[1]=b=>m(u).room=b),"aria-label":"Room"},[y[4]||(y[4]=i("option",{value:""},"All rooms",-1)),(p(!0),h(U,null,le(l.value,b=>(p(),h("option",{key:b,value:b},v(b),9,Dp))),128))],512),[[vn,m(u).room]]),i("button",{type:"button",class:"chip-toggle",id:"hide-placed","aria-pressed":m(u).hidePlaced?"true":"false",title:"Hide what is already on this screen",onClick:y[2]||(y[2]=b=>m(u).hidePlaced=!m(u).hidePlaced)},"Hide placed",8,Fp)])]),i("div",Np,[(p(!0),h(U,null,le(a.value.slice(0,80),b=>we((p(),h("button",{key:b.id,type:"button",class:"ent",title:b.id,disabled:o(b.id)||r.value,onClick:k=>m(Ha)(b.id)},[i("span",{class:ie(["av mdi",f(b)]),style:Pe({color:m(xt)(b.id)[2],background:m(xt)(b.id)[3]})},v(m(u).inventory.icons?m(ke)(m(sn)(b.id)):m(xt)(b.id)[1]),7),i("span",Hp,[i("b",null,v(b.name),1),i("small",null,v([m(xt)(b.id)[0],b.area,b.device].filter(Boolean).join(" · ")),1)]),i("span",{class:ie(["add",{done:o(b.id)}])},v(o(b.id)?"✓":"+"),3)],8,Rp)),[[m(Ka),{kind:"entity",id:b.id}]])),128)),a.value.length?a.value.length>80?(p(),h("p",jp,v(a.value.length)+" results. Keep typing to narrow it down.",1)):R("",!0):(p(),h("p",Up,v(m(u).hidePlaced&&!m(u).search&&!m(u).filter&&!m(u).room?"Everything here is already on this screen.":"No entities found. Try a different name, room or filter."),1))]),i("div",Bp,v(r.value?`This screen holds ${m(yn)} tiles${m(yn)<48?"; update its firmware for 48":""}.`:"Drag onto a page, or tap + to add to the first free slot. ⌘K searches everything."),1)]))}}),Wp={class:"canvas-head"},Kp={id:"count"},qp={key:0,id:"no-tiles"},Yp={key:1},zp={key:2,id:"positions-hint",class:"warn"},Jp={class:"pages",id:"layout-preview","aria-label":"Screen layout"},Gp={class:"page-label"},Xp=["tabindex"],Zp=Ee({__name:"LayoutView",setup(e){const t=$(()=>u.layout),n=$(()=>u.drag.preview||Ve(t.value)),s=$(()=>gt(n.value,t.value.pages)),o=$(()=>Zc()),l=$(()=>s.valueDc(t.value.tiles)&&!Zt(0,2,26)?`Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${lt.value?.firmware||"unknown"}) shifts the tiles up to the first free slot until that update.`:"");function r(d){d.target.closest(".device, .page-label, .canvas-head")||u.inspector&&He()}return(d,f)=>(p(),h(U,null,[i("div",{class:"canvas",id:"canvas",onClick:r},[i("div",Wp,[i("b",Kp,v(s.value)+" "+v(s.value===1?"page":"pages")+" · "+v(t.value.tiles.length)+" / "+v(m(yn))+" tiles",1),t.value.tiles.length?(p(),h("span",Yp,"Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.")):(p(),h("span",qp,"Add your first light, scene, or device from the library.")),a.value?(p(),h("span",zp,v(a.value),1)):R("",!0)]),i("div",Jp,[(p(!0),h(U,null,le(o.value,c=>(p(),Xe(Ap,{key:c,page:c-1,entries:n.value,pages:s.value,moving:m(u).drag.moving},null,8,["page","entries","pages","moving"]))),128)),i("div",{class:ie(["page ghost",{disabled:!l.value}])},[i("div",Gp,[i("span",null,"Page "+v(o.value+1),1)]),i("div",{class:ie(["device",{cyd:m(lt)?.board!=="guition"}]),id:"add-page",role:"button",tabindex:l.value?0:-1,onClick:f[0]||(f[0]=c=>l.value&&m(ol)()),onKeydown:f[1]||(f[1]=$a(dt(c=>l.value&&m(ol)(),["prevent"]),["enter"]))},v(l.value?"+ Add page":`${m(Bt)} pages is the most`),43,Xp)],2)])]),_e(Vp)],64))}}),Qp={class:"status",id:"settings-status",role:"status"},eh={key:0,class:"set-grid",id:"settings-groups"},th={class:"mdi"},nh=["data-setting","title","onClick"],sh=["id"],oh={class:"s-control"},ih=["id","aria-checked","aria-labelledby","disabled","onClick"],lh=["aria-labelledby"],ah=["aria-pressed","disabled","onClick"],rh={key:2,class:"step"},uh=["aria-label","disabled","onPointerdown","onClick"],ch={class:"mdi"},dh=["id","aria-labelledby"],fh=["aria-label","disabled","onPointerdown","onClick"],ph={class:"mdi"},hh=Ee({__name:"SettingsTab",setup(e){const t=$(()=>vi()),n=$(()=>On()),s=$(()=>t.value?.owner==="screen"&&!lt.value?.online),o=$(()=>ld.map(I=>({...I,rows:I.rows.filter(E=>t.value?.keys.includes(E.key))})).filter(I=>I.rows.length)),l=I=>s.value||!!t.value?.unavailable.includes(I.key),a=I=>I.needs?!!n.value[I.needs]:!0,r=$(()=>s.value?"This screen is offline. Its settings can change once it's back.":u.settingPending?"Saving…":t.value?.owner==="screen"?"Changes apply on the screen at once, and show up here when they change there.":"Changes apply at once. Firmware 0.2.49 lets the screen keep them itself."),d=(I,E)=>{const x=n.value[I.key];return l(I)||!a(I)||x===null||x===void 0?!0:I.kind!=="moment"&&ul(I,x,E,!1,n.value)===x},f=new WeakMap;function c(I,E,x){const S=On(),M=ul(I,S[I.key],E,x,S);M!==S[I.key]&&Wn(I.key,M,600)}function y(I,E,x){const S=I.currentTarget;if(S.disabled||I.button!==0)return;const M={timer:0,held:!1};f.set(S,M);let G=0;M.timer=window.setTimeout(function C(){M.held=!0,G+=1,c(E,x,G>5),M.timer=window.setTimeout(C,180)},450)}function b(I){const E=f.get(I.currentTarget);E&&clearTimeout(E.timer)}function k(I,E,x){const S=f.get(I.currentTarget);if(S?.held){S.held=!1;return}c(E,x,!1)}return(I,E)=>(p(),h("div",{class:ie(["settings",{offline:s.value}]),id:"general-settings"},[i("span",Qp,v(r.value),1),t.value?(p(),h("div",eh,[(p(!0),h(U,null,le(o.value,x=>(p(),h("section",{key:x.title,class:"set-card"},[i("h4",null,[i("span",th,v(m(ke)(x.icon)),1),Z(v(x.title),1)]),(p(!0),h(U,null,le(x.rows,S=>(p(),h("div",{key:S.key,class:ie(["srow",[`setting-${S.kind}`,{inactive:!a(S)||l(S)}]]),"data-setting":S.key,title:l(S)&&!s.value?"This entity is off in Home Assistant, or the screen is restarting.":"",onClick:M=>S.kind==="toggle"&&M.target.closest(".srow")===M.currentTarget&&!M.target.closest("button")&&!l(S)&&m(Wn)(S.key,!n.value[S.key],150)},[i("span",{class:"s-label",id:`setting-label-${S.key}`},v(S.label),9,sh),i("div",oh,[S.kind==="toggle"?(p(),h("button",{key:0,type:"button",class:ie(["switch",{unknown:n.value[S.key]===null||n.value[S.key]===void 0}]),role:"switch",id:`setting-${S.key}`,"aria-checked":n.value[S.key]?"true":"false","aria-labelledby":`setting-label-${S.key}`,disabled:l(S),onClick:dt(M=>m(Wn)(S.key,!n.value[S.key],150),["stop"])},null,10,ih)):S.kind==="choice"?(p(),h("div",{key:1,class:"seg",role:"group","aria-labelledby":`setting-label-${S.key}`},[(p(!0),h(U,null,le(S.options,([M,G])=>(p(),h("button",{key:String(M),type:"button","aria-pressed":n.value[S.key]===M?"true":"false",disabled:l(S),onClick:C=>m(Wn)(S.key,M,150)},v(G),9,ah))),128))],8,lh)):(p(),h("div",rh,[i("button",{type:"button","aria-label":`${S.label} lower`,disabled:d(S,-1),onPointerdown:M=>y(M,S,-1),onPointerup:b,onPointercancel:b,onPointerleave:b,onClick:M=>k(M,S,-1)},[i("span",ch,v(m(ke)("F0374")),1)],40,uh),i("output",{id:`setting-${S.key}`,"aria-labelledby":`setting-label-${S.key}`},v(m(cd)(S,n.value)),9,dh),i("button",{type:"button","aria-label":`${S.label} higher`,disabled:d(S,1),onPointerdown:M=>y(M,S,1),onPointerup:b,onPointercancel:b,onPointerleave:b,onClick:M=>k(M,S,1)},[i("span",ph,v(m(ke)("F0415")),1)],40,fh)]))])],10,nh))),128))]))),128))])):R("",!0)],2))}}),vh={class:"seg wrap",role:"group"},mh=["disabled","aria-pressed","onClick"],tt=Ee({__name:"Segmented",props:{choices:{},value:{},disabled:{type:Boolean}},emits:["pick"],setup(e,{emit:t}){const n=t;return(s,o)=>(p(),h("div",vh,[(p(!0),h(U,null,le(e.choices,([l,a])=>(p(),h("button",{key:String(l),type:"button",disabled:e.disabled,"aria-pressed":String(l)===String(e.value)?"true":"false",onClick:r=>n("pick",l)},v(a),9,mh))),128))]))}}),yh={class:"f"},gh={key:0},bh={key:1,class:"warn"},wh=["aria-expanded"],_h={class:"tx"},kh={key:0,class:"mono"},$h={class:"link"},xh={key:0,class:"picker"},Sh={class:"action-list"},Ch=["aria-pressed","onClick"],Th={key:0,class:"hint"},Ah={key:1,class:"warn"},Eh={class:"f-label"},Ph=["aria-label","value","onChange"],Oh=["value"],Ih={key:3,class:"action-number"},Mh=["aria-label","value","onInput"],Lh=["onClick"],Dh={key:4,class:"action-number"},Fh=["aria-label","min","max","step","value","placeholder","onInput"],Nh={key:0},Rh=["aria-label","value","placeholder","onInput"],Hh={key:6},Uh={key:0,class:"warn"},jh={key:3},Bh=Ee({__name:"ActionPicker",props:{tile:{}},setup(e){const t=e,n=$(()=>u.entityActions[t.tile.entity]),s=$(()=>t.tile.options?.action),o=$(()=>n.value?.find(C=>C.action===s.value?.action)),l=$(()=>u.actionPickerOpen||!s.value),a=$(()=>{const C=u.actionSearch.trim().toLocaleLowerCase();return(n.value||[]).filter(V=>`${V.name} ${V.action} ${V.description}`.toLocaleLowerCase().includes(C))});n.value===void 0&&Yc(t.tile.entity);function r(C){const V=C.action===s.value?.action;t.tile.options={...t.tile.options,action:V?s.value:{action:C.action}},u.actionPickerOpen=!1,Ze()}function d(C,V){const O={...t.tile.options?.action?.data||{}};V===void 0?delete O[C]:O[C]=V,t.tile.options={...t.tile.options,action:{action:s.value.action,...Object.keys(O).length?{data:O}:{}}},Ze()}const f=$(()=>{if(!o.value)return[];const C=t.tile.options?.action?.data||{};return o.value.fields.filter(V=>V.required&&C[V.key]===void 0).map(V=>V.name)}),c=C=>Object.keys(C.selector||{})[0]||"text",y=C=>(C.selector||{})[c(C)]||{},b=C=>C.example===void 0?"":typeof C.example=="string"?C.example:JSON.stringify(C.example);function k(C){const V=y(C),O=c(C);return Array.isArray(C.options)?C.options.map(A=>[A,A]):O==="select"&&Array.isArray(V.options)&&!V.multiple?V.options.map(A=>A&&typeof A=="object"?[String(A.value),String(A.label??A.value)]:[String(A),String(A)]):null}const I=C=>"#"+C.map(V=>Math.max(0,Math.min(255,Number(V)||0)).toString(16).padStart(2,"0")).join(""),E=C=>Array.isArray(C)&&C.length===3?I(C):"#ffffff",x=C=>C===void 0?"":typeof C=="string"?C:JSON.stringify(C);function S(C,V){const O=V.trim();if(!O)return d(C.key,void 0);if(c(C)==="text")return d(C.key,V);try{d(C.key,JSON.parse(O))}catch{d(C.key,V)}}function M(C,V){d(C.key,V===""||!Number.isFinite(Number(V))?void 0:Number(V))}const G=C=>y(C).unit_of_measurement||c(C)==="color_temp"&&y(C).unit||"";return(C,V)=>(p(),h("div",yh,[V[3]||(V[3]=i("span",{class:"f-label"},"Action",-1)),n.value===void 0?(p(),h("small",gh,"Asking Home Assistant which actions this entity has…")):n.value===null?(p(),h("small",bh,"Home Assistant isn't answering right now. Try again in a moment.")):(p(),h(U,{key:2},[i("button",{type:"button",class:"row","aria-expanded":l.value?"true":"false",onClick:V[0]||(V[0]=O=>m(u).actionPickerOpen=!l.value)},[i("span",_h,[i("b",null,v(o.value?o.value.name:s.value?s.value.action:"Choose an action"),1),s.value?(p(),h("small",kh,v(s.value.action),1)):R("",!0)]),i("span",$h,v(l.value?"Close":"Change"),1)],8,wh),l.value?(p(),h("div",xh,[we(i("input",{"onUpdate:modelValue":V[1]||(V[1]=O=>m(u).actionSearch=O),type:"search",placeholder:"Search, for example toggle, position, or play","aria-label":"Search for an action"},null,512),[[Fe,m(u).actionSearch]]),i("div",Sh,[(p(!0),h(U,null,le(a.value,O=>(p(),h("button",{key:O.action,type:"button",class:"action-choice","aria-pressed":O.action===s.value?.action?"true":"false",onClick:A=>r(O)},[i("strong",null,v(O.name),1),i("small",null,v(O.description?`${O.action} · ${O.description}`:O.action),1)],8,Ch))),128)),a.value.length?R("",!0):(p(),h("p",Th,"No action found."))])])):R("",!0),s.value&&!o.value?(p(),h("small",Ah,"Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.")):R("",!0),o.value&&!l.value?(p(),h(U,{key:2},[(p(!0),h(U,null,le(o.value.fields,O=>(p(),h("div",{key:O.key,class:"f"},[i("span",Eh,v(O.required?O.name:`${O.name} (optional)`),1),c(O)==="boolean"?(p(),Xe(tt,{key:0,choices:[["","Not set"],["true","On"],["false","Off"]],value:s.value?.data?.[O.key]===void 0?"":String(s.value?.data?.[O.key]),onPick:A=>d(O.key,A===""?void 0:A==="true")},null,8,["value","onPick"])):k(O)&&k(O).length<=4?(p(),Xe(tt,{key:1,choices:[["","Not set"],...k(O)],value:s.value?.data?.[O.key]===void 0?"":String(s.value?.data?.[O.key]),onPick:A=>d(O.key,A===""?void 0:A)},null,8,["choices","value","onPick"])):k(O)?(p(),h("select",{key:2,"aria-label":O.name,value:String(s.value?.data?.[O.key]??""),onChange:A=>d(O.key,A.target.value===""?void 0:A.target.value)},[V[2]||(V[2]=i("option",{value:""},"Not set",-1)),(p(!0),h(U,null,le(k(O),([A,w])=>(p(),h("option",{key:A,value:A},v(w),9,Oh))),128))],40,Ph)):c(O)==="color_rgb"?(p(),h("div",Ih,[i("input",{type:"color","aria-label":O.name,value:E(s.value?.data?.[O.key]),onInput:A=>d(O.key,[1,3,5].map(w=>parseInt(A.target.value.slice(w,w+2),16)))},null,40,Mh),i("button",{type:"button",class:"btn quiet mini",onClick:A=>d(O.key,void 0)},"Not set",8,Lh)])):c(O)==="number"||c(O)==="color_temp"?(p(),h("div",Dh,[i("input",{type:"number","aria-label":O.name,min:y(O).min,max:y(O).max,step:y(O).step==="any"?"any":y(O).step,value:s.value?.data?.[O.key]??"",placeholder:b(O),onInput:A=>M(O,A.target.value)},null,40,Fh),G(O)?(p(),h("span",Nh,v(G(O)),1)):R("",!0)])):(p(),h("input",{key:5,type:"text","aria-label":O.name,value:x(s.value?.data?.[O.key]),placeholder:b(O),onInput:A=>S(O,A.target.value)},null,40,Rh)),O.description?(p(),h("small",Hh,v(O.description),1)):R("",!0)]))),128)),f.value.length?(p(),h("small",Uh,"Home Assistant needs "+v(f.value.join(", "))+".",1)):R("",!0)],64)):R("",!0),m(Zt)(0,2,58)?R("",!0):(p(),h("small",jh,"The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic."))],64))]))}}),Vh={class:"f"},Wh=["aria-expanded"],Kh={class:"av mdi"},qh={class:"tx"},Yh={class:"link"},zh={key:0,class:"picker"},Jh=["aria-pressed","title"],Gh={class:"mdi"},Xh=["aria-pressed"],Zh={class:"icon-list"},Qh={class:"icon-grid"},ev=["title","aria-label","aria-pressed","onClick"],tv={class:"mdi"},nv={key:0,class:"hint"},sv={key:1},Ja=Ee({__name:"IconPicker",props:{selected:{},automatic:{},autoLabel:{},allowNone:{type:Boolean},note:{}},emits:["pick"],setup(e,{emit:t}){const n=e,s=t,o=pe(""),l=$(()=>vs(n.selected)),a=$(()=>n.selected==="none"?"":ke(l.value?.cp||n.automatic)),r=$(()=>n.selected==="none"?"No icon":l.value?.label||n.autoLabel),d=$(()=>{const f=o.value.trim().toLocaleLowerCase();return(u.inventory.icons?.groups||[]).map(c=>({label:c.label,icons:c.icons.filter(y=>!f||`${y.label} ${y.name.replaceAll("-"," ")} ${c.label}`.toLocaleLowerCase().includes(f))})).filter(c=>c.icons.length)});return(f,c)=>(p(),h("div",Vh,[c[5]||(c[5]=i("span",{class:"f-label"},"Icon",-1)),i("button",{type:"button",class:"row","aria-expanded":m(u).iconPickerOpen?"true":"false",onClick:c[0]||(c[0]=y=>m(u).iconPickerOpen=!m(u).iconPickerOpen)},[i("span",Kh,v(a.value),1),i("span",qh,[i("b",null,v(r.value),1)]),i("span",Yh,v(m(u).iconPickerOpen?"Close":"Change"),1)],8,Wh),m(u).iconPickerOpen?(p(),h("div",zh,[we(i("input",{"onUpdate:modelValue":c[1]||(c[1]=y=>o.value=y),type:"search",placeholder:"Search, for example lamp, music, or door","aria-label":"Search for an icon"},null,512),[[Fe,o.value]]),i("button",{type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="auto"?"true":"false",title:e.autoLabel,onClick:c[2]||(c[2]=y=>s("pick","auto"))},[i("span",Gh,v(m(ke)(e.automatic)),1),i("span",null,v(e.autoLabel),1)],8,Jh),e.allowNone?(p(),h("button",{key:0,type:"button",class:"icon-choice icon-auto","aria-pressed":e.selected==="none"?"true":"false",title:"No icon",onClick:c[3]||(c[3]=y=>s("pick","none"))},[...c[4]||(c[4]=[i("span",{class:"mdi"},null,-1),i("span",null,"No icon, text only",-1)])],8,Xh)):R("",!0),i("div",Zh,[(p(!0),h(U,null,le(d.value,y=>(p(),h("section",{key:y.label},[i("small",null,v(y.label),1),i("div",Qh,[(p(!0),h(U,null,le(y.icons,b=>(p(),h("button",{key:b.name,type:"button",class:"icon-choice",title:b.label,"aria-label":`Icon: ${b.label}`,"aria-pressed":e.selected===b.name?"true":"false",onClick:k=>s("pick",b.name)},[i("span",tv,v(m(ke)(b.cp)),1)],8,ev))),128))])]))),128)),d.value.length?R("",!0):(p(),h("p",nv,"No icon found."))])])):R("",!0),e.note?(p(),h("small",sv,v(e.note),1)):R("",!0)]))}}),ov={class:"dr-head"},iv={class:"tx"},lv={class:"mono"},av={class:"dr-body"},rv={class:"f"},uv=["value","placeholder"],cv={key:1,class:"f"},dv={key:2,class:"f"},fv={key:0,class:"warn"},pv={class:"f"},hv={key:0},vv={key:3,class:"f"},mv={key:4,class:"f"},yv={key:5,class:"f"},gv={key:7,class:"f"},bv={key:0,class:"warn"},wv={key:8,class:"f"},_v={class:"f"},kv={class:"sw"},$v=["aria-label","aria-pressed","onClick"],xv={class:"dr-foot"},Sv=Ee({__name:"TileInspector",props:{tile:{}},setup(e){const t=e,n=$(()=>t.tile.entity.split(".")[0]),s=$(()=>hs(t.tile.entity)),o=$(()=>ps(t.tile.entity)),l=$(()=>u.layout?gt(Ve(u.layout),u.layout.pages):1),a=$(()=>ht(t.tile.slot)+1),r=K=>!u.layout?.tiles.some(B=>ht(B.slot)===K-1),d=$(()=>{const K=Array.from({length:Math.min(Bt,l.value+1)},(B,ne)=>ne+1);return o.value>K.length&&K.push(o.value),K.map(B=>[B,r(B)?`${B} (empty)`:String(B)])}),f=$(()=>nl.value?o.value>l.value?{text:`This screen has no page ${o.value}, so a tap opens its last page. Choose another page.`,warn:!0}:{text:"A tap on the tile opens that page.",warn:!1}:{text:"Navigation tiles need firmware 0.2.62: press Update on the screen first.",warn:!1}),c=$(()=>!u.layout?.tiles.some(K=>ve(K)!==ve(t.tile)&&ht(K.slot)===a.value-1)),y=$(()=>{const K=Array.from({length:l.value},(B,ne)=>[ne+1,String(ne+1)]);return l.valueo.value?[["single","Normal"],["wide","Double-width"]]:[["single","Normal"],["wide","Double-width"],["full","Full page"]]),k=$(()=>o.value?"":nl.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."),I=$(()=>u.capabilities[t.tile.entity]),E=(K,B)=>t.tile.options?.[K]??B,x=$(()=>E("display",n.value==="screen"?"digital":"standard")),S=$(()=>{const K=n.value==="screen"?[["digital","Digital clock"],["analog","Analog clock"]]:[["standard","Name and status"],["watch","Large value"]],B=I.value;return n.value==="weather"&&(!B||B.displays.includes("forecast")||x.value==="forecast")&&K.push(["forecast","Weather forecast"]),n.value==="sensor"&&(!B||B.displays.includes("graph")||x.value==="graph")&&K.push(["graph","Graph"]),n.value==="sun"&&K.push(["sunpath","Sun path"]),K}),M=$(()=>{const K=I.value;return K&&x.value==="graph"&&!K.displays.includes("graph")?"Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.":K&&x.value==="forecast"&&!K.displays.includes("forecast")?"This weather service has no daily forecast in Home Assistant. Choose another display.":""}),G=$(()=>E("size","single")),C=$(()=>u.inventory.controls?.[n.value]),V=$(()=>E("controls",G.value==="full"?"none":C.value?.default)),O=$(()=>{const K=I.value;return(C.value?.choices||[]).filter(B=>!K||B.key==="none"||B.key===V.value||K.controls.includes(B.key)).map(B=>[B.key,B.label])}),A=$(()=>{const K=I.value;return K&&V.value!=="none"&&!K.controls.includes(V.value)?{text:"Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.",warn:!0}:{text:Zt(0,2,19)?G.value==="full"?"At the bottom of the full-page tile; a tap anywhere else works as set below.":"On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below.":"The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.",warn:!1}}),w=$(()=>E("tap","auto")),T=$(()=>{const K=[["auto","Automatic"],["detail","Open control"],["none","View only"]];return((I.value?I.value.toggle:tl.includes(n.value))||w.value==="toggle")&&K.push(["toggle","On / off"]),K.push(["action","Perform action"]),K}),te=$(()=>w.value==="toggle"&&I.value&&!I.value.toggle?{text:"Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.",warn:!0}:w.value==="toggle"&&!tl.includes(n.value)&&!Zt(0,2,58)?{text:"The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.",warn:!1}:w.value==="toggle"?{text:"Hold the tile to open its card.",warn:!1}:null),$e=$(()=>E("inline","none")),Me=$(()=>Bc.includes(n.value)&&(!I.value||I.value.inline||$e.value==="slider")),X=$(()=>$e.value==="slider"&&I.value&&!I.value.inline),j=$(()=>E("history_hours",24)),z=$(()=>Object.entries(u.inventory.backgrounds||{})),ce=$(()=>!!u.inventory.entities.find(K=>K.id===t.tile.entity)?.icon),de=$(()=>!!u.inventory.icons&&(n.value!=="screen"||o.value>0)&&!["forecast","sunpath"].includes(x.value));function he(K){t.tile.name=K,Ze()}function Q(){u.inspector={kind:"inspect",entity:t.tile.entity}}return(K,B)=>(p(),h(U,null,[i("div",ov,[i("span",{class:"av mdi",style:Pe({color:m(xt)(e.tile.entity)[2],background:m(xt)(e.tile.entity)[3]})},v(m(ke)(m(Bn)(e.tile))),5),i("span",iv,[i("b",null,v(e.tile.name||s.value),1),i("small",lv,v(e.tile.entity),1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:B[0]||(B[0]=(...ne)=>m(He)&&m(He)(...ne))},"✕")]),i("div",av,[i("div",rv,[B[12]||(B[12]=i("label",{class:"f-label",for:"tile-name"},"Name on the screen",-1)),i("input",{id:"tile-name",value:e.tile.name,placeholder:s.value,maxlength:"60",onInput:B[1]||(B[1]=ne=>he(ne.target.value))},null,40,uv)]),de.value?(p(),Xe(Ja,{key:0,selected:e.tile.options?.icon||"auto",automatic:m(sn)(e.tile.entity),"auto-label":`Automatic (${ce.value?"from Home Assistant":"default"})`,note:m(Zt)(0,2,18)?"":"The screen shows a chosen icon from firmware 0.2.18.",onPick:B[2]||(B[2]=ne=>m(Kt)(e.tile,"icon",ne))},null,8,["selected","automatic","auto-label","note"])):R("",!0),o.value?(p(),h("div",cv,[B[13]||(B[13]=i("span",{class:"f-label"},"Goes to page",-1)),_e(tt,{choices:d.value,value:o.value,onPick:B[3]||(B[3]=ne=>m(Qc)(e.tile,Number(ne)))},null,8,["choices","value"]),i("small",{class:ie({warn:f.value.warn})},v(f.value.text),3)])):(p(),h("div",dv,[B[14]||(B[14]=i("span",{class:"f-label"},"Display",-1)),_e(tt,{choices:S.value,value:x.value,onPick:B[4]||(B[4]=ne=>m(Kt)(e.tile,"display",ne))},null,8,["choices","value"]),M.value?(p(),h("small",fv,v(M.value),1)):R("",!0)])),i("div",pv,[B[15]||(B[15]=i("span",{class:"f-label"},"Size",-1)),_e(tt,{choices:b.value,value:G.value,onPick:B[5]||(B[5]=ne=>m(Kt)(e.tile,"size",ne))},null,8,["choices","value"]),k.value?(p(),h("small",hv,v(k.value),1)):R("",!0)]),y.value.length>1?(p(),h("div",vv,[B[16]||(B[16]=i("span",{class:"f-label"},"Page",-1)),_e(tt,{choices:y.value,value:a.value,onPick:B[6]||(B[6]=ne=>m(Xc)(e.tile,Number(ne)-1))},null,8,["choices","value"])])):R("",!0),C.value&&G.value!=="single"&&!o.value?(p(),h("div",mv,[B[17]||(B[17]=i("span",{class:"f-label"},"Direct control on the tile",-1)),_e(tt,{choices:O.value,value:V.value,onPick:B[7]||(B[7]=ne=>m(Kt)(e.tile,"controls",ne))},null,8,["choices","value"]),i("small",{class:ie({warn:A.value.warn})},v(A.value.text),3)])):R("",!0),n.value!=="screen"&&!o.value?(p(),h("div",yv,[B[18]||(B[18]=i("span",{class:"f-label"},"On tap",-1)),_e(tt,{choices:T.value,value:w.value,onPick:B[8]||(B[8]=ne=>m(Kt)(e.tile,"tap",ne))},null,8,["choices","value"]),te.value?(p(),h("small",{key:0,class:ie({warn:te.value.warn})},v(te.value.text),3)):R("",!0)])):R("",!0),n.value!=="screen"&&!o.value&&w.value==="action"?(p(),Xe(Bh,{key:6,tile:e.tile},null,8,["tile"])):R("",!0),Me.value&&!o.value?(p(),h("div",gv,[B[19]||(B[19]=i("span",{class:"f-label"},"Small slider on the tile",-1)),_e(tt,{choices:[["none","No"],["slider","Yes, control directly"]],value:$e.value,onPick:B[9]||(B[9]=ne=>m(Kt)(e.tile,"inline",ne))},null,8,["value"]),X.value?(p(),h("small",bv,"Home Assistant has nothing a slider can change for this entity. Choose No.")):R("",!0)])):R("",!0),n.value==="sensor"&&!o.value?(p(),h("div",wv,[B[20]||(B[20]=i("span",{class:"f-label"},"History",-1)),_e(tt,{choices:[[1,"1 hour"],[6,"6 hours"],[24,"24 hours"]],value:j.value,onPick:B[10]||(B[10]=ne=>m(Kt)(e.tile,"history_hours",Number(ne)))},null,8,["value"])])):R("",!0),i("div",_v,[B[21]||(B[21]=i("span",{class:"f-label"},"Pastel background",-1)),i("div",kv,[(p(!0),h(U,null,le(z.value,([ne,Qe])=>(p(),h("button",{key:ne,type:"button","aria-label":`Background: ${Qe.label}`,"aria-pressed":(e.tile.options?.background||"auto")===ne?"true":"false",onClick:Mn=>m(Kt)(e.tile,"background",ne)},[i("i",{class:ie(Qe.color?"":ne==="none"?"none":"auto"),style:Pe(Qe.color?{background:Qe.color}:void 0)},null,6),Z(v(Qe.label),1)],8,$v))),128))])])]),i("div",xv,[i("button",{type:"button",class:"btn danger",onClick:B[11]||(B[11]=ne=>m(Ua)(e.tile))},"Remove"),B[22]||(B[22]=i("span",{class:"spacer"},null,-1)),n.value!=="screen"?(p(),h("button",{key:0,type:"button",class:"btn quiet",onClick:Q},"Read current data")):R("",!0)])],64))}}),Cv={class:"dr-head"},Tv={class:"av mdi",style:{background:"var(--seg)",color:"var(--ink-2)"}},Av={class:"tx"},Ev={class:"dr-body"},Pv={class:"f"},Ov=["value"],Iv={class:"f"},Mv={class:"f-label",id:"topbar-caption"},Lv={style:{"text-transform":"none","letter-spacing":"0","font-weight":"500"}},Dv={class:"items",id:"topbar-chips",role:"list","aria-labelledby":"topbar-caption"},Fv=["data-index","aria-label","onPointerdown","onClick","onKeydown"],Nv={class:"tx"},Rv=["aria-label","onClick"],Hv=["disabled","title"],Uv={class:"live",id:"topbar-live"},jv={class:"f"},Bv={class:"f"},Vv={key:1,class:"f"},Wv={key:2},Kv={key:0,class:"dr-foot"},qv=["disabled"],Yv=["disabled"],zv=Ee({__name:"TopbarInspector",props:{index:{}},setup(e){const t=e,n=$(()=>Vt()),s=$(()=>n.value[t.index]),o=$(()=>(u.fontsVersion,u.now,u.topbarPreviews,Sa(n.value,Na.value,u.layout?.title||"Home",qt))),l=$(()=>o.value.dropped),a=$(()=>u.inventory.header?.min_firmware||"0.2.32"),r=$(()=>{const[X,j,z]=a.value.split(".").map(Number);return Zt(X,j,z)}),d=$(()=>r.value?l.value.size?"Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name.":"Drag to reorder; tap an item to configure it.":`Sensors, the date, and the analog clock appear from firmware ${a.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`),f=(X,j)=>{const z=qt(X);return z.shown?l.value.has(j)?"Doesn't fit next to the name":z.analog?"Dial":z.text:"Hidden: not active right now"},c=X=>{const j=qt(X);return j.analog||X.type!=="entity"?vs(xa[X.type])?.cp:j.icon},y=X=>u.topbarAdded?.key===qe(X)&&Date.now()-u.topbarAdded.time<1200;function b(X){u.layout&&(u.layout.title=X,Ze())}function k(X){const j=[...n.value];j[t.index]={...j[t.index],...X},as(j)}const I=$(()=>s.value?qt(s.value).shown?l.value.has(t.index)?"Doesn't currently fit next to the name":"How it looks on the screen":"Hidden right now: not active":""),E=$(()=>On().clock_24h!==!1),x=$(()=>({clock:ui(E.value,new Date(u.now)),analog:"Small dial with the time",date:ci(new Date(u.now))})),S=pe({index:-1,active:!1,moved:!1});let M=null,G=0,C=null,V=0;function O(X,j){X.button!==0||X.target.closest(".x")||(X.pointerType!=="touch"&&X.preventDefault(),S.value={index:j,active:!1,moved:!1},M={x:X.clientX,y:X.clientY},C=X.pointerId,clearTimeout(G),X.pointerType==="touch"&&(G=window.setTimeout(A,260)),document.addEventListener("pointermove",T),document.addEventListener("pointerup",te),document.addEventListener("pointercancel",te))}function A(){S.value.active=!0,document.addEventListener("touchmove",w,{passive:!1})}function w(X){S.value.active&&X.preventDefault()}function T(X){if(X.pointerId!==C||!M)return;if(!S.value.active){const ce=Math.hypot(X.clientX-M.x,X.clientY-M.y);if(X.pointerType==="touch"){ce>10&&(clearTimeout(G),M=null);return}if(ce<6)return;A()}const j=[...document.querySelectorAll(".items .item[data-index]")];let z=S.value.index;if(j.forEach((ce,de)=>{const he=ce.getBoundingClientRect();deS.value.index&&X.clientY>he.top+he.height/2&&(z=Math.max(z,de))}),z!==S.value.index&&u.layout){const ce=[...n.value];ce.splice(z,0,...ce.splice(S.value.index,1)),u.layout.header={items:ce};const de=t.index===S.value.index;S.value.index=z,S.value.moved=!0,de&&Lt(z)}}function te(X){X.pointerId===C&&(clearTimeout(G),document.removeEventListener("pointermove",T),document.removeEventListener("pointerup",te),document.removeEventListener("pointercancel",te),document.removeEventListener("touchmove",w),S.value.active&&(V=Date.now()+400,S.value.moved&&Ze()),S.value={index:-1,active:!1,moved:!1},M=null,C=null)}function $e(X){Date.now()(p(),h(U,null,[i("div",Cv,[i("span",Tv,v(s.value?m(ke)(c(s.value)||"F0150"):m(ke)("F0150")),1),i("span",Av,[i("b",null,v(s.value?s.value.type==="entity"?m(hs)(s.value.entity):m(Vn)(s.value):"Top bar"),1),i("small",{class:ie({mono:s.value?.type==="entity"})},v(s.value?s.value.type==="entity"?s.value.entity:"From the screen itself, also works without Home Assistant":`${n.value.length} of ${m(zt)()} items`),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:j[0]||(j[0]=(...z)=>m(He)&&m(He)(...z))},"✕")]),i("div",Ev,[i("div",Pv,[j[10]||(j[10]=i("label",{class:"f-label",for:"title"},"Left: name",-1)),i("input",{id:"title",value:m(u).layout?.title,maxlength:"60",placeholder:"For example: Living room",onInput:j[1]||(j[1]=z=>b(z.target.value))},null,40,Ov)]),i("div",Iv,[i("span",Mv,[j[11]||(j[11]=Z("Right: time, sensors, or a clock ",-1)),i("span",Lv," · "+v(n.value.length)+" / "+v(m(zt)()),1)]),i("div",Dv,[(p(!0),h(U,null,le(n.value,(z,ce)=>(p(),h("div",{key:m(qe)(z)+ce,class:ie(["item",{selected:ce===e.index,"is-hidden":!m(qt)(z).shown,"is-overflow":l.value.has(ce),"just-added":y(z),"dragging-chip":S.value.active&&S.value.index===ce}]),role:"listitem",tabindex:"0","data-index":ce,"aria-label":`${m(Vn)(z)}, slot ${ce+1}. Enter: configure, arrows: move`,onPointerdown:de=>O(de,ce),onClick:de=>$e(ce),onKeydown:de=>Me(de,ce)},[j[12]||(j[12]=i("span",{class:"grip","aria-hidden":"true"},"⋮⋮",-1)),i("span",{class:"av mdi",style:Pe(m(qt)(z).color?{color:m(qt)(z).color}:void 0)},v(c(z)?m(ke)(c(z)):""),5),i("span",Nv,[i("b",null,v(m(Vn)(z)),1),i("small",null,v(f(z,ce)),1)]),i("button",{type:"button",class:"x","aria-label":`Remove ${m(Vn)(z)} from the top bar`,onClick:dt(de=>m(rl)(ce),["stop"])},"✕",8,Rv)],42,Fv))),128)),i("button",{type:"button",class:"ghost-btn",id:"topbar-add",disabled:n.value.length>=m(zt)(),title:n.value.length>=m(zt)()?`Maximum ${m(zt)()} items`:"Add the time, date, analog clock, or an entity",onClick:j[2]||(j[2]=(...z)=>m(il)&&m(il)(...z))},"+ Add time, date, or an entity",8,Hv)]),i("small",{id:"topbar-hint",class:ie({warn:l.value.size>0&&r.value})},v(d.value),3)]),s.value?(p(),h(U,{key:0},[i("div",Uv,[i("small",null,v(I.value),1),_e(za,{items:[s.value],single:""},null,8,["items"])]),s.value.type==="entity"?(p(),h(U,{key:0},[i("div",jv,[j[13]||(j[13]=i("span",{class:"f-label"},"What to show",-1)),_e(tt,{choices:(m(u).inventory.header?.contents||[]).map(z=>[z.key,z.label]),value:s.value.content,onPick:j[3]||(j[3]=z=>k({content:z}))},null,8,["choices","value"]),j[14]||(j[14]=i("small",null,"Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.",-1))]),_e(Ja,{selected:s.value.icon||"auto",automatic:m(u).topbarPreviews[m(qe)(s.value)]?.auto_icon||m(sn)(s.value.entity),"auto-label":"Automatic (like Home Assistant)","allow-none":"",onPick:j[4]||(j[4]=z=>k({icon:z}))},null,8,["selected","automatic"]),i("div",Bv,[j[15]||(j[15]=i("span",{class:"f-label"},"Show",-1)),_e(tt,{choices:(m(u).inventory.header?.shows||[]).map(z=>[z.key,z.label]),value:s.value.show,onPick:j[5]||(j[5]=z=>k({show:z}))},null,8,["choices","value"]),j[16]||(j[16]=i("small",null,"Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.",-1))])],64)):s.value.type!=="date"?(p(),h("div",Vv,[j[17]||(j[17]=i("span",{class:"f-label"},"Format",-1)),_e(tt,{choices:[["24","24 hour"],["12","12 hour"]],value:E.value?"24":"12",onPick:j[6]||(j[6]=z=>m(Wn)("clock_24h",z==="24",150))},null,8,["value"]),j[18]||(j[18]=i("small",null,"The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.",-1))])):(p(),h("small",Wv,v(x.value.date)+" today. The date follows the screen's own clock.",1))],64)):R("",!0)]),s.value?(p(),h("div",Kv,[i("button",{type:"button",class:"btn danger",onClick:j[7]||(j[7]=z=>m(rl)(e.index))},"Remove"),j[19]||(j[19]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",disabled:e.index===0,onClick:j[8]||(j[8]=z=>m(xo)(e.index,e.index-1)&&m(Lt)(e.index-1))},"↑ Up",8,qv),i("button",{type:"button",class:"btn quiet",disabled:e.index>=n.value.length-1,onClick:j[9]||(j[9]=z=>m(xo)(e.index,e.index+1)&&m(Lt)(e.index+1))},"↓ Down",8,Yv)])):R("",!0)],64))}}),Jv={class:"dr-head"},Gv={class:"tx"},Xv={class:"dr-body"},Zv={class:"f"},Qv={class:"options"},em=["disabled","onClick"],tm={class:"mdi"},nm={class:"tx"},sm={key:0,class:"f"},om={class:"options"},im=["disabled","onClick"],lm={class:"mdi"},am={class:"tx"},rm={class:"f"},um={class:"options"},cm=["disabled","onClick"],dm={class:"mdi"},fm={class:"tx"},pm={key:0,class:"hint"},hm={key:1,class:"hint"},vm={class:"dr-foot"},mm=Ee({__name:"TopbarAdd",setup(e){const t=pe(""),n=$(()=>new Set(Vt().map(qe))),s=$(()=>On().clock_24h!==!1),o=$(()=>({clock:ui(s.value,new Date(u.now)),analog:"Small dial with the time",date:ci(new Date(u.now))})),l=$(()=>u.inventory.header?.suggestions?.[u.selected||""]||[]),a=$(()=>{const d=t.value.trim().toLocaleLowerCase();return u.inventory.entities.filter(f=>`${f.name} ${f.id} ${f.area||""} ${f.device||""}`.toLocaleLowerCase().includes(d))}),r=d=>({type:"entity",entity:d,content:"state",icon:"auto",show:"always"});return(d,f)=>(p(),h(U,null,[i("div",Jv,[f[4]||(f[4]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"18px"}},"+",-1)),i("span",Gv,[f[3]||(f[3]=i("b",null,"Add to the top bar",-1)),i("small",null,v(m(Vt)().length)+" of "+v(m(zt)())+" slots used",1)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:f[0]||(f[0]=(...c)=>m(He)&&m(He)(...c))},"✕")]),i("div",Xv,[i("div",Zv,[f[5]||(f[5]=i("span",{class:"f-label"},"From the screen itself",-1)),i("div",Qv,[(p(!0),h(U,null,le(m(u).inventory.header?.builtin||[],c=>(p(),h("button",{key:c.type,type:"button",class:"option",disabled:n.value.has(m(qe)({type:c.type})),onClick:y=>m(So)({type:c.type})},[i("span",tm,v(m(ke)(m(vs)(m(xa)[c.type])?.cp||"F0150")),1),i("span",nm,[i("strong",null,v(c.label),1),i("small",null,v(n.value.has(m(qe)({type:c.type}))?"Already added":o.value[c.type]),1)])],8,em))),128))])]),l.value.length?(p(),h("div",sm,[f[6]||(f[6]=i("span",{class:"f-label"},"Suggestions from Home Assistant",-1)),i("div",om,[(p(!0),h(U,null,le(l.value,c=>(p(),h("button",{key:m(qe)(c.item),type:"button",class:"option",disabled:n.value.has(m(qe)(c.item)),onClick:y=>m(So)(c.item)},[i("span",lm,v(m(ke)(c.icon||m(sn)(c.item.entity))),1),i("span",am,[i("strong",null,v(c.label),1),i("small",null,v(n.value.has(m(qe)(c.item))?"Already added":[c.name,c.area].filter(Boolean).join(" · ")),1)])],8,im))),128))])])):R("",!0),i("div",rm,[f[7]||(f[7]=i("label",{class:"f-label",for:"topbar-search"},"An entity",-1)),we(i("input",{id:"topbar-search","onUpdate:modelValue":f[1]||(f[1]=c=>t.value=c),type:"search",placeholder:"Search by name, room, or entity, e.g. temperature or door","aria-label":"Search for an entity for the top bar"},null,512),[[Fe,t.value]]),i("div",um,[(p(!0),h(U,null,le(a.value.slice(0,40),c=>(p(),h("button",{key:c.id,type:"button",class:"option",disabled:n.value.has(m(qe)(r(c.id))),onClick:y=>m(So)(r(c.id))},[i("span",dm,v(m(ke)(m(sn)(c.id))),1),i("span",fm,[i("strong",null,v(c.name),1),i("small",null,v([c.area,c.id].filter(Boolean).join(" · ")),1)])],8,cm))),128)),a.value.length?a.value.length>40?(p(),h("p",hm,v(a.value.length)+" results. Keep typing to narrow it down.",1)):R("",!0):(p(),h("p",pm,"No entities found."))])])]),i("div",vm,[f[8]||(f[8]=i("span",{class:"spacer"},null,-1)),i("button",{type:"button",class:"btn quiet",onClick:f[2]||(f[2]=(...c)=>m(He)&&m(He)(...c))},"Cancel")])],64))}}),ym={class:"dr-head"},gm={class:"tx"},bm={class:"dr-body",id:"inspector-section"},wm={id:"inspection-summary"},_m={key:0,class:"hint warn"},km={key:1,class:"hint"},$m={id:"inspection"},xm=Ee({__name:"InspectPanel",props:{entity:{}},setup(e){const t=e,n=pe(null),s=pe("Reading…"),o=pe("");async function l(){if(u.selected){n.value=null,o.value="",s.value="Fetching current HA status...";try{const r=await yt(`screens/${encodeURIComponent(u.selected)}/inspect`),d=t.entity?r.tiles.filter(f=>f.entity===t.entity):r.tiles;n.value=d,s.value=JSON.stringify(t.entity?d[0]:r,null,2)}catch(r){o.value=r.message,s.value="",ge(r.message)}}}const a=r=>{const d=u.layout?.tiles.find(f=>f.entity===r)?.options||{};return`Small slider: ${d.inline==="slider"?"yes":"no"} · Display: ${Da[d.display||"standard"]||d.display} · Size: ${jc[d.size]||"normal"} · Control: ${Nc({entity:r,options:d},u.inventory)} · Background: ${u.inventory.backgrounds?.[d.background||"auto"]?.label||"Default"}`};return _n(l),pn(()=>t.entity,l),(r,d)=>(p(),h(U,null,[i("div",ym,[d[2]||(d[2]=i("span",{class:"av",style:{background:"var(--seg)",color:"var(--ink-2)","font-size":"16px"}},"⌕",-1)),i("span",gm,[d[1]||(d[1]=i("b",null,"Current data",-1)),i("small",{class:ie({mono:e.entity})},v(e.entity||"Connection, status, and supported properties"),3)]),i("button",{type:"button",class:"icon-btn","aria-label":"Close",onClick:d[0]||(d[0]=(...f)=>m(He)&&m(He)(...f))},"✕")]),i("div",bm,[i("div",wm,[o.value?(p(),h("p",_m,v(o.value),1)):n.value?R("",!0):(p(),h("p",km,"Fetching current HA status...")),(p(!0),h(U,null,le(n.value||[],(f,c)=>(p(),h("article",{key:c,class:"inspection-tile"},[i("strong",null,v(f.entity),1),i("span",null,"Status: "+v(f.word||f.state),1),i("small",null,v(a(f.entity)),1)]))),128))]),i("pre",$m,v(s.value),1)]),i("div",{class:"dr-foot"},[i("button",{type:"button",class:"btn quiet",onClick:l},"Read again"),d[3]||(d[3]=i("span",{class:"spacer"},null,-1))])],64))}}),Sm=["aria-hidden"],Cm=Ee({__name:"Drawer",setup(e){const t=$(()=>!!(u.inspector&&(u.inspector.kind!=="tile"||go.value)));return(n,s)=>(p(),h("aside",{class:ie(["drawer",{open:t.value}]),id:"tile-sheet","aria-hidden":t.value?"false":"true",onClick:s[0]||(s[0]=dt(()=>{},["stop"]))},[m(u).inspector?(p(),h(U,{key:0},[m(u).inspector.kind==="tile"&&m(go)?(p(),Xe(Sv,{key:0,tile:m(go)},null,8,["tile"])):m(u).inspector.kind==="bar"?(p(),Xe(zv,{key:1,index:m(u).inspector.index},null,8,["index"])):m(u).inspector.kind==="bar-add"?(p(),Xe(mm,{key:2})):m(u).inspector.kind==="inspect"?(p(),Xe(xm,{key:3,entity:m(u).inspector.entity},null,8,["entity"])):R("",!0)],64)):R("",!0)],10,Sm))}}),Tm={class:"main-head"},Am={class:"eyebrow",id:"screen-name"},Em=["title"],Pm={class:"head-right"},Om={class:"seg",role:"tablist"},Im=["aria-pressed"],Mm=["aria-pressed"],Lm={key:1,id:"dirty",class:"chip dirty"},Dm=["disabled"],Fm={key:0,class:"spin small"},Nm=["aria-expanded"],Rm={key:3,class:"menu",role:"menu"},Hm=["disabled","title"],Um=["disabled","aria-expanded"],jm={key:0,class:"sub"},Bm=["onClick"],Vm=["disabled","title"],Wm={class:"body",id:"body"},Km=Ee({__name:"ScreenView",setup(e){const t=$(()=>lt.value),n=$(()=>t.value.online?`${t.value.delivery} · ${t.value.status}`:"Offline · changes are saved"),s=$(()=>t.value.update?.available&&t.value.online&&t.value.update?.profile&&t.value.update?.host),o=$(()=>u.inventory.screens.filter(E=>E.id!==t.value.id&&E.layout?.tiles?.length)),l=pe(!1),a=pe(null);function r(){u.menuOpen=!1,l.value=!1}function d(){r(),u.overrideProfile=t.value.update?.profile||null,u.overrideFriendly=t.value.name,Oe("#override")}function f(){r(),u.selectedTile=null,u.inspector={kind:"inspect"}}function c(E){r(),!(u.dirty&&!confirm("Replace the unsaved layout with a copy of the other screen's?"))&&nd(E)}function y(){r(),a.value?.click()}async function b(E){const x=E.target,S=x.files?.[0];x.value="",S&&(u.dirty&&!confirm("Replace the unsaved layout with the imported one?")||od(await S.text()))}function k(E){if(E.key==="Escape")if(u.menuOpen)r();else{if(u.palette)return;u.inspector&&!E.target?.closest?.(".picker")&&He()}else(E.metaKey||E.ctrlKey)&&E.key.toLowerCase()==="s"&&(E.preventDefault(),u.dirty&&jo())}function I(E){u.menuOpen&&!E.target.closest(".head-right .menu, #more")&&r()}return _n(()=>{document.addEventListener("keydown",k),document.addEventListener("click",I)}),In(()=>{document.removeEventListener("keydown",k),document.removeEventListener("click",I)}),(E,x)=>(p(),h(U,null,[i("header",Tm,[i("div",null,[i("span",Am,v(t.value.name),1),i("h1",null,v(m(u).layout?.title||"Home"),1)]),i("span",{id:"delivery",class:ie(["chip",{off:!t.value.online}]),title:n.value},[x[9]||(x[9]=i("span",{class:"dot"},null,-1)),Z(v(n.value),1)],10,Em),i("div",Pm,[i("div",Om,[i("button",{type:"button",id:"tab-layout",role:"tab","aria-pressed":m(u).tab==="layout"?"true":"false",onClick:x[0]||(x[0]=S=>m(u).tab="layout")},"Layout",8,Im),i("button",{type:"button",id:"tab-settings",role:"tab","aria-pressed":m(u).tab==="settings"?"true":"false",onClick:x[1]||(x[1]=S=>{m(u).tab="settings",m(He)()})},"Screen settings",8,Mm)]),m(u).dirty?(p(),h("span",Lm,"Unsaved changes")):(p(),h("span",{key:0,id:"dirty",class:ie(["chip",{sent:m(u).saved}])},v(m(u).saved?"Saved · sent to screen":"All saved"),3)),m(u).dirty?(p(),h("button",{key:2,id:"save",type:"button",class:"btn primary",disabled:m(u).busy,title:"⌘S",onClick:x[2]||(x[2]=S=>m(jo)())},[m(u).busy?(p(),h("span",Fm)):R("",!0),Z(v(m(u).busy?"Saving…":"Save & send"),1)],8,Dm)):R("",!0),i("button",{id:"more",type:"button",class:"icon-btn","aria-label":"More","aria-haspopup":"menu","aria-expanded":m(u).menuOpen?"true":"false",onClick:x[3]||(x[3]=dt(S=>{m(u).menuOpen=!m(u).menuOpen,l.value=!1},["stop"]))},"···",8,Nm),m(u).menuOpen?(p(),h("div",Rm,[i("button",{type:"button",role:"menuitem",id:"identify",disabled:!m(gn)(t.value)||!t.value.online,title:m(gn)(t.value)?"":"Needs firmware 0.2.31 or newer",onClick:x[4]||(x[4]=S=>{r(),m(ja)(t.value)})},[...x[10]||(x[10]=[Z("Identify ",-1),i("small",null,"blink the screen",-1)])],8,Hm),i("button",{type:"button",role:"menuitem",id:"inspect",onClick:f},[...x[11]||(x[11]=[Z("Read current data ",-1),i("small",null,"from Home Assistant",-1)])]),x[17]||(x[17]=i("div",{class:"sep"},null,-1)),i("button",{type:"button",role:"menuitem",id:"copy-layout",disabled:!o.value.length,"aria-expanded":l.value?"true":"false",onClick:x[5]||(x[5]=dt(S=>l.value=!l.value,["stop"]))},[x[12]||(x[12]=Z("Copy layout from… ",-1)),i("small",null,v(o.value.length?`${o.value.length} screen${o.value.length===1?"":"s"}`:"no other screen"),1)],8,Um),l.value?(p(),h("div",jm,[(p(!0),h(U,null,le(o.value,S=>(p(),h("button",{key:S.id,type:"button",role:"menuitem",onClick:M=>c(S.id)},[Z(v(S.name)+" ",1),i("small",null,v(S.layout.tiles.length)+" tiles",1)],8,Bm))),128))])):R("",!0),i("button",{type:"button",role:"menuitem",id:"export-layout",onClick:x[6]||(x[6]=S=>{r(),m(Va)()})},[...x[13]||(x[13]=[Z("Export layout ",-1),i("small",null,"JSON",-1)])]),i("button",{type:"button",role:"menuitem",id:"import-layout",onClick:y},[...x[14]||(x[14]=[Z("Import layout… ",-1),i("small",null,"JSON",-1)])]),x[18]||(x[18]=i("div",{class:"sep"},null,-1)),i("button",{type:"button",role:"menuitem",id:"open-override",disabled:!t.value.update?.profile,title:t.value.update?.profile?"":"No ESPHome profile found for this screen",onClick:d},[...x[15]||(x[15]=[Z("Override YAML ",-1),i("small",null,"advanced",-1)])],8,Vm),i("button",{type:"button",role:"menuitem",onClick:x[7]||(x[7]=S=>{r(),m(Oe)("#firmware")})},"Firmware & USB"),s.value?(p(),h("button",{key:1,type:"button",role:"menuitem",onClick:x[8]||(x[8]=S=>{r(),m(Vo)(t.value)})},[x[16]||(x[16]=Z("Update firmware ",-1)),i("small",null,v(t.value.update?.target),1)])):R("",!0)])):R("",!0),i("input",{ref_key:"fileInput",ref:a,type:"file",accept:"application/json,.json",hidden:"",onChange:b},null,544)])]),i("div",Wm,[m(u).tab==="layout"?(p(),Xe(Zp,{key:0})):(p(),Xe(hh,{key:1})),_e(Cm)])],64))}}),qm={key:0,id:"choose",class:"empty"},Ym={key:1,id:"empty",class:"empty"},zm=Ee({__name:"EmptyState",setup(e){return(t,n)=>m(u).inventory.screens.length?(p(),h("section",qm,[...n[1]||(n[1]=[i("span",{class:"empty-icon"},"▦",-1),i("h2",null,"Choose a screen",-1),i("p",null,"Pick one of your screens on the left to change its tiles, top bar and settings.",-1)])])):(p(),h("section",Ym,[n[2]||(n[2]=i("span",{class:"empty-icon"},"▦",-1)),n[3]||(n[3]=i("h2",null,"Start with a screen",-1)),n[4]||(n[4]=i("p",null,"Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.",-1)),i("button",{id:"start",type:"button",class:"btn primary",onClick:n[0]||(n[0]=s=>m(Oe)("#new-screen"))},"Install new screen")]))}}),Jm={class:"panel",id:"settings-view"},Gm={class:"panel-head"},Xm={class:"card-grid"},Zm={class:"card"},Qm={class:"tools"},ey={key:0,class:"card updates",id:"updates"},ty={id:"updates-hint"},ny=["aria-valuenow"],sy={class:"progress-text"},oy={key:0,class:"log-lines"},iy={key:2,class:"whatsnew"},ly={class:"check"},ay=["checked"],ry={class:"card"},uy={class:"tools"},cy={class:"card",id:"claude"},dy={class:"actions"},fy=["disabled"],py={id:"claude-path"},hy=Ee({__name:"AppSettingsView",setup(e){const t=$(()=>u.inventory.updates),n=$(()=>u.inventory.screens.filter(y=>y.update?.available).length),s=$(()=>t.value?t.value.busy?`Updating to firmware ${t.value.target}…`:u.inventory.screens.length?n.value?`Firmware ${t.value.target} is available for ${n.value} screen${n.value===1?"":"s"}.`:`All screens have firmware ${t.value.target}.`:`No screen paired yet. New screens get firmware ${t.value.target}.`:""),o=$(()=>u.inventory.screens.find(y=>y.update?.state==="running"||u.updating.includes(y.id))),l=$(()=>o.value?xn(o.value):null),a=$(()=>(u.firmwareJob?.logs||[]).slice(-12).join(`
+`)),r=$(()=>{const y=u.inventory.changelog,b=t.value?.target;return!Array.isArray(y)||!b?[]:y.filter(k=>k.firmware===b).flatMap(k=>k.lines).slice(0,10)}),d=$(()=>u.inventory.claude_skill),f=pe(!1);async function c(){f.value=!0;try{await fd()}finally{f.value=!1}}return(y,b)=>(p(),h("div",Jm,[i("div",Gm,[b[6]||(b[6]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Settings"),i("h1",{id:"settings-title"},"Everything around your screens.")],-1)),i("button",{type:"button",class:"btn quiet",id:"close-settings",onClick:b[0]||(b[0]=k=>m(Oe)(""))},"← My screens")]),i("div",Xm,[i("section",Zm,[b[9]||(b[9]=i("h2",null,"Screens",-1)),b[10]||(b[10]=i("p",null,"Add a screen, or build and reinstall the firmware of one you already have.",-1)),i("div",Qm,[i("button",{type:"button",class:"tool",onClick:b[1]||(b[1]=k=>m(Oe)("#new-screen"))},[...b[7]||(b[7]=[i("span",{class:"tool-icon"},"+",-1),i("span",{class:"tx"},[i("b",null,"New screen"),i("small",null,"Connect and install")],-1)])]),i("button",{type:"button",class:"tool",onClick:b[2]||(b[2]=k=>m(Oe)("#firmware"))},[...b[8]||(b[8]=[i("span",{class:"tool-icon"},"⇪",-1),i("span",{class:"tx"},[i("b",null,"Firmware & USB"),i("small",null,"Build and install via USB or Wi-Fi")],-1)])])])]),t.value?(p(),h("section",ey,[b[12]||(b[12]=i("h2",null,"Firmware updates",-1)),i("p",ty,v(s.value),1),o.value&&l.value?(p(),h(U,{key:0},[i("div",{class:"progress",role:"progressbar","aria-valuenow":l.value.percent,"aria-valuemin":"0","aria-valuemax":"100"},[i("i",{style:Pe({width:l.value.percent+"%"})},null,4)],8,ny),i("div",sy,[i("span",null,v(o.value.name)+" · "+v(l.value.percent)+" %",1),i("span",null,v(l.value.text),1)]),a.value?(p(),h("pre",oy,v(a.value),1)):R("",!0)],64)):R("",!0),t.value.pending&&!t.value.busy&&t.value.pending>=2?(p(),h("button",{key:1,id:"update-all",type:"button",class:"btn primary",onClick:b[3]||(b[3]=(...k)=>m(cl)&&m(cl)(...k))},"Update all "+v(t.value.pending)+" screens",1)):R("",!0),r.value.length&&!m(Bo)()?(p(),h("details",iy,[i("summary",null,"What's new in firmware "+v(t.value.target),1),i("ul",null,[(p(!0),h(U,null,le(r.value,k=>(p(),h("li",{key:k},v(k),1))),128))])])):R("",!0),i("label",ly,[i("input",{type:"checkbox",id:"auto-update",checked:!!t.value.auto,onChange:b[4]||(b[4]=k=>m(dd)(k.target.checked))},null,40,ay),b[11]||(b[11]=i("span",null,[Z("Update automatically every night"),i("small",null,"Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.")],-1))])])):R("",!0),i("section",ry,[b[14]||(b[14]=i("h2",null,"Alerts",-1)),b[15]||(b[15]=i("p",null,"A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors, and a form to try one.",-1)),i("div",uy,[i("button",{type:"button",class:"tool",onClick:b[5]||(b[5]=k=>m(Oe)("#alerts"))},[...b[13]||(b[13]=[i("span",{class:"tool-icon"},"!",-1),i("span",{class:"tx"},[i("b",null,"Alerts"),i("small",null,"Full-screen notifications, from an automation")],-1)])])])]),i("section",cy,[b[18]||(b[18]=i("h2",null,"Claude",-1)),b[19]||(b[19]=i("p",null,'Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.',-1)),i("div",dy,[i("button",{type:"button",id:"claude-install",class:ie(["btn",d.value?.installed&&d.value.current?"quiet":"primary"]),disabled:!d.value||f.value,onClick:c},v(!d.value||!d.value.installed?"Install for Claude Code":d.value.current?"Install again":"Update the skill"),11,fy),i("span",{id:"claude-status",class:ie(["chip",d.value&&d.value.installed?d.value.current?"good":"update":""]),role:"status"},v(d.value?d.value.installed?d.value.current?"● Installed":"Installed · a newer version is ready":"Not installed":"Loading…"),3)]),i("small",null,[b[16]||(b[16]=Z("For Claude Code in Home Assistant. Writes one file to ",-1)),i("code",py,v(d.value?.path||"/homeassistant/.claude/skills/esp-screens"),1),b[17]||(b[17]=Z("; installing again replaces it.",-1))]),b[20]||(b[20]=i("div",{class:"actions"},[i("a",{class:"btn quiet",id:"claude-download",href:"api/claude-skill.zip",download:"esp-screens.zip"},"Download for claude.ai"),i("small",null,"Upload the zip in Claude under Customize → Skills.")],-1))])])]))}}),vy={class:"panel",id:"installer"},my={class:"panel-head"},yy={class:"tx"},gy={id:"install-title"},by={key:0},wy={class:"boards"},_y={class:"board"},ky={class:"board"},$y={class:"field"},xy={class:"node-line"},Sy={id:"node-preview"},Cy={key:0,class:"field",id:"node-label"},Ty={key:1,id:"wifi-fields",class:"wifi"},Ay={class:"hint",id:"wifi-status"},Ey={key:0,class:"field",id:"wifi-ssid-label"},Py={key:1,class:"field",id:"wifi-password-label"},Oy={class:"field"},Iy={key:0,value:"usb"},My=["value"],Ly={id:"target-hint"},Dy={key:2,class:"hint",id:"install-note"},Fy={class:"actions"},Ny=["disabled"],Ry={id:"install-status",class:"status-line error",role:"status"},Hy={key:1,id:"install-progress",class:"card"},Uy={class:"progress-head"},jy={key:0,class:"spin",id:"progress-spin"},By={id:"progress-title"},Vy={id:"progress-detail"},Wy={key:0,id:"install-download",class:"card",style:{background:"var(--surface-2)"}},Ky=["href","download"],qy={class:"steps",id:"download-steps"},Yy={key:1,id:"install-result",class:"field"},zy={class:"key-box"},Jy={class:"steps",id:"install-steps"},Gy=["open"],Xy={id:"install-log",class:"log"},Zy={class:"actions"},Qy="https://web.esphome.io/?dashboard_install",eg="Download · flash from your own computer",tg=Ee({__name:"InstallerView",setup(e){const t=bn({board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),n=bn({view:"setup",file:null,friendly:"",board:"cyd",target:"",apiKey:null,nodeEdited:!1,jobState:null,picked:!1,action:null}),s=pe(!1),o=pe(null),l=pe(null),a=pe([]),r=pe(""),d=pe(""),f=pe(!1),c=pe(!1),y=pe(null);let b=0;function k(me){return me.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^[^a-z]+/,"").slice(0,30).replace(/-+$/,"")||"screen"}function I(me){const N=me.replace(/^\/dev\/serial\/by-id\/usb-/,"").replace(/-if\d+(-port\d+)?$/,"").replace(/_/g," ");return`USB · ${N===me?me.replace(/^\/dev\//,""):N}`}pn(()=>t.friendly_name,()=>{n.nodeEdited||(t.name=k(t.friendly_name))});const E=$(()=>t.name||"…"),x=$(()=>o.value?.ports||[]),S=$(()=>o.value?.wifi),M=$(()=>S.value?.state==="new"||S.value?.state==="missing"),G=$(()=>S.value?.missing||[]),C=$(()=>S.value?.state==="new"?"Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically.":"Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines."),V=$(()=>S.value?.state==="ready"?"Wi-Fi comes from your ESPHome secrets.yaml.":S.value?.state==="invalid"?"secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.":""),O=$(()=>t.target==="usb"?"Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer.":t.target==="download"?"Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi.":t.target?x.value.length>1?"More than one board connected: choose this screen's port.":"Once over USB; after that, everything is wireless.":"The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."),A=$(()=>t.target==="download"?"Build & download":t.target?"Install":"Save profile"),w=$(()=>o.value?.job?.state==="running"&&!(n.file&&o.value.job.file===n.file)),T=$(()=>f.value||S.value?.state==="invalid"||t.target==="usb"||!!t.target&&(w.value||!o.value?.available));function te(){const me=t.target;(me==="download"||me===""?n.picked:x.value.includes(me))||(t.target=x.value[0]||"usb")}async function $e(){let me;try{me=await yt("firmware")}catch(at){r.value=at.message;return}o.value=me;const N=me.job,fe=N&&n.file&&N.file===n.file;if(fe&&(n.jobState=N.state),n.view==="setup"){if(fe&&N.state==="running"){Me(N,me.logs||[]);return}te(),r.value=w.value?`A build or installation is already running (${N.file}). Wait for it to finish.`:!me.available&&t.target?"The ESPHome CLI is missing from this installation; only saving the profile is possible.":V.value}else n.view==="progress"&&fe&&(l.value=N,a.value=me.logs||[],N.state!=="running"&&N.state!=="success"&&(c.value=!0))}function Me(me,N){n.view="progress",n.jobState=me.state,n.action=me.action,l.value=me,a.value=N}const X=$(()=>l.value?.state==="running"),j=$(()=>n.view==="done"||l.value?.state==="success"),z=$(()=>n.action==="download"),ce=$(()=>n.view==="done"?"Profile saved.":X.value?"One moment…":j.value?z.value?"Ready to download.":"Done.":"That didn't work."),de=$(()=>n.view==="done"?`${n.file} is in the ESPHome folder`:X.value?l.value?.stage==="upload"?`Writing firmware to ${n.friendly}…`:"Building firmware…":j.value?z.value?`Firmware for ${n.friendly} is ready`:`Firmware is on ${n.friendly}`:z.value?"Build failed":"Install failed"),he=$(()=>n.view==="done"?"Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:":X.value?l.value?.stage==="upload"?"Don't disconnect the USB cable yet.":`A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${z.value?"build":"installation"} keeps running and you'll find it again under New screen.`:j.value?z.value?"Put it on the screen from your own computer, then pair the screen with Home Assistant.":`The screen boots up and connects to your Wi-Fi.${n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""} Pair it with Home Assistant now:`:a.value.filter(me=>/error/i.test(me)).pop()||a.value.filter(me=>/failed/i.test(me)).pop()||"See the log below."),Q=$(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.file||"")}/download`,name:(n.file||"").replace(/\.yaml$/,"")+".factory.bin"})),K=$(()=>[["Go to Home Assistant → Settings → Devices & services.",` This happens outside ESP Screens. Home Assistant discovers ${n.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],["Paste the API key"," above when Home Assistant asks for an encryption key."],["Allow HA actions:"," ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],["Choose your tiles."," Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."]]);async function B(me){const N=me.target;if(n.nodeEdited||(t.name=k(t.friendly_name)),!!N.reportValidity()){f.value=!0,d.value="";try{const fe={board:t.board,friendly_name:t.friendly_name,name:t.name,target:t.target};M.value&&(G.value.includes("wifi_ssid")&&(fe.wifi_ssid=t.wifi_ssid),G.value.includes("wifi_password")&&(fe.wifi_password=t.wifi_password));const at=await it("firmware/profiles","POST",fe);Object.assign(n,{file:at.file,apiKey:at.api_key,friendly:t.friendly_name.trim(),board:t.board,target:t.target}),t.wifi_password="",at.job?Me(at.job,[]):n.view="done"}catch(fe){d.value=fe.message}finally{f.value=!1}}}async function ne(){try{if(!z.value){const{ports:N}=await yt("firmware");!N.includes(n.target)&&N.length===1&&(n.target=N[0])}const me=await it("firmware/jobs","POST",z.value?{file:n.file,action:"download"}:{file:n.file,action:"install",target:n.target});Me(me,[])}catch(me){ge(me.message)}}function Qe(){Object.assign(n,{view:"setup",file:null,apiKey:null,nodeEdited:!1,jobState:null,target:"",picked:!1,action:null}),Object.assign(t,{board:"cyd",friendly_name:"",name:"",wifi_ssid:"",wifi_password:"",target:""}),s.value=!1,l.value=null,a.value=[],d.value="",r.value="",c.value=!1,$e()}function Mn(){n.view==="progress"&&n.jobState!=="running"&&(n.view="done"),Oe("")}return _n(()=>{$e(),b=window.setInterval($e,3e3)}),In(()=>clearInterval(b)),(me,N)=>(p(),h("div",vy,[i("div",my,[i("div",yy,[N[13]||(N[13]=i("span",{class:"eyebrow"},"New screen",-1)),i("h1",gy,v(n.view==="setup"?"Connect and install.":ce.value),1),n.view==="setup"?(p(),h("p",by,"ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.")):R("",!0)]),i("button",{type:"button",class:"btn quiet",id:"close-install","aria-label":"Close",onClick:Mn},"← Back")]),n.view==="setup"?(p(),h("form",{key:0,id:"install-form",class:"card",onSubmit:dt(B,["prevent"])},[i("fieldset",null,[N[16]||(N[16]=i("legend",null,"Which screen do you have?",-1)),i("div",wy,[i("label",_y,[we(i("input",{type:"radio",name:"board",value:"cyd","onUpdate:modelValue":N[0]||(N[0]=fe=>t.board=fe)},null,512),[[Xi,t.board]]),N[14]||(N[14]=i("span",null,[i("b",null,"CYD · 2.8 inch"),i("small",null,"ESP32-2432S028 · 320 × 240")],-1))]),i("label",ky,[we(i("input",{type:"radio",name:"board",value:"guition","onUpdate:modelValue":N[1]||(N[1]=fe=>t.board=fe)},null,512),[[Xi,t.board]]),N[15]||(N[15]=i("span",null,[i("b",null,"Guition · 4 inch"),i("small",null,"ESP32-S3-4848S040 · 480 × 480 · GT911")],-1))])])]),i("div",$y,[N[18]||(N[18]=i("label",{class:"f-label",for:"friendly_name"},"Name",-1)),we(i("input",{id:"friendly_name",name:"friendly_name","onUpdate:modelValue":N[2]||(N[2]=fe=>t.friendly_name=fe),required:"",maxlength:"60",placeholder:"Living room screen",autocomplete:"off"},null,512),[[Fe,t.friendly_name]]),i("small",xy,[N[17]||(N[17]=Z("Device name ",-1)),i("code",Sy,v(E.value),1),i("button",{type:"button",class:"btn link mini",id:"edit-node",onClick:N[3]||(N[3]=fe=>{n.nodeEdited=!0,s.value=!0})},"customize")]),N[19]||(N[19]=i("small",null,"Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.",-1))]),s.value?(p(),h("div",Cy,[N[20]||(N[20]=i("label",{class:"f-label",for:"node-name"},"Device name",-1)),we(i("input",{id:"node-name",name:"name","onUpdate:modelValue":N[4]||(N[4]=fe=>t.name=fe),pattern:"[a-z][a-z0-9\\-]{0,29}",maxlength:"30",autocomplete:"off",onInput:N[5]||(N[5]=fe=>n.nodeEdited=!0)},null,544),[[Fe,t.name]]),N[21]||(N[21]=i("small",null,"Lowercase letters, digits, and dashes. A different name for each screen.",-1))])):R("",!0),M.value?(p(),h("fieldset",Ty,[N[24]||(N[24]=i("legend",null,"Wi-Fi",-1)),i("p",Ay,v(C.value),1),G.value.includes("wifi_ssid")?(p(),h("div",Ey,[N[22]||(N[22]=i("label",{class:"f-label",for:"wifi_ssid"},"Wi-Fi name (2.4 GHz)",-1)),we(i("input",{id:"wifi_ssid",name:"wifi_ssid","onUpdate:modelValue":N[6]||(N[6]=fe=>t.wifi_ssid=fe),autocomplete:"off"},null,512),[[Fe,t.wifi_ssid]])])):R("",!0),G.value.includes("wifi_password")?(p(),h("div",Py,[N[23]||(N[23]=i("label",{class:"f-label",for:"wifi_password"},"Wi-Fi password",-1)),we(i("input",{id:"wifi_password",name:"wifi_password",type:"password","onUpdate:modelValue":N[7]||(N[7]=fe=>t.wifi_password=fe),autocomplete:"new-password"},null,512),[[Fe,t.wifi_password]])])):R("",!0)])):R("",!0),i("div",Oy,[N[26]||(N[26]=i("label",{class:"f-label",for:"install-target"},"Install via",-1)),we(i("select",{id:"install-target",name:"target","onUpdate:modelValue":N[8]||(N[8]=fe=>t.target=fe),onChange:N[9]||(N[9]=fe=>{n.picked=!0,$e()})},[x.value.length?R("",!0):(p(),h("option",Iy,"USB · no board found on the Home Assistant machine yet")),(p(!0),h(U,null,le(x.value,fe=>(p(),h("option",{key:fe,value:fe},v(I(fe)),9,My))),128)),i("option",{value:"download"},v(eg)),N[25]||(N[25]=i("option",{value:""},"Later · save profile only",-1))],544),[[vn,t.target]]),i("small",Ly,v(O.value),1)]),r.value?(p(),h("p",Dy,v(r.value),1)):R("",!0),i("div",Fy,[i("button",{type:"submit",class:"btn primary",id:"install-go",disabled:T.value},v(A.value),9,Ny),i("span",Ry,v(d.value),1)])],32)):(p(),h("div",Hy,[i("div",Uy,[X.value?(p(),h("span",jy)):(p(),h("span",{key:1,class:ie(["outcome",j.value?"ok":"bad"]),id:"progress-mark"},v(j.value?"✓":"✕"),3)),i("strong",By,v(de.value),1)]),i("p",Vy,v(he.value),1),j.value&&z.value&&n.view!=="done"?(p(),h("div",Wy,[i("a",{class:"btn primary",id:"download-firmware",href:Q.value.href,download:Q.value.name},"Download "+v(Q.value.name),9,Ky),i("ol",qy,[N[30]||(N[30]=i("li",null,[i("b",null,"Plug the screen into your computer"),Z(" with a USB data cable.")],-1)),i("li",null,[i("b",null,[N[27]||(N[27]=Z("Open ",-1)),i("a",{href:Qy,target:"_blank",rel:"noopener"},"ESPHome Web")]),N[28]||(N[28]=Z(" in Chrome or Edge, click Connect and choose the screen's USB port.",-1))]),i("li",null,[N[29]||(N[29]=i("b",null,"Click Install",-1)),Z(" and select "+v(Q.value.name)+". The screen restarts and joins your Wi-Fi."+v(n.board==="cyd"?" The CYD first asks for a touch calibration: tap the crosshairs.":""),1)])]),N[31]||(N[31]=i("small",null,"The file holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])):R("",!0),j.value?(p(),h("div",Yy,[i("div",zy,[N[32]||(N[32]=i("span",null,"API key",-1)),i("code",{id:"api-key",ref_key:"keyBox",ref:y},v(n.apiKey||""),513),i("button",{type:"button",class:"btn quiet mini",id:"copy-key",onClick:N[10]||(N[10]=fe=>m(ft)(n.apiKey||"",y.value))},"Copy")]),i("ol",Jy,[(p(!0),h(U,null,le(K.value,([fe,at],Ln)=>(p(),h("li",{key:Ln},[i("b",null,v(fe),1),Z(v(at),1),Ln===0?(p(),h("button",{key:0,type:"button",class:"btn quiet mini",onClick:N[11]||(N[11]=(...ln)=>m(Fs)&&m(Fs)(...ln))},"Open Devices & services")):R("",!0)]))),128))])])):R("",!0),n.view!=="done"?(p(),h("details",{key:2,id:"install-log-wrap",class:"log-wrap",open:c.value,onToggle:N[12]||(N[12]=fe=>c.value=fe.target.open)},[N[33]||(N[33]=i("summary",null,"ESPHome log",-1)),i("pre",Xy,v(a.value.join(`
+`)),1)],40,Gy)):R("",!0),i("div",Zy,[!X.value&&!j.value?(p(),h("button",{key:0,type:"button",class:"btn primary",id:"install-retry",onClick:ne},"Retry")):R("",!0),i("button",{type:"button",class:"btn quiet",id:"install-close",onClick:Qe},v(j.value?"Install another screen":"Start over"),1),i("button",{type:"button",class:ie(["btn",j.value?"primary":"quiet"]),onClick:Mn},v(j.value?"Done":"Close"),3)])]))]))}}),ng={class:"panel",id:"firmware-dialog"},sg={class:"panel-head"},og={class:"card"},ig={class:"card-grid"},lg={class:"field"},ag=["value"],rg={class:"field"},ug={key:0,value:"usb"},cg=["value"],dg={key:0,class:"field",id:"firmware-host-label"},fg=["placeholder"],pg={class:"actions"},hg=["disabled"],vg=["disabled"],mg=["disabled"],yg={key:0,class:"spin"},gg={id:"firmware-status",class:"status-line",role:"status"},bg={key:0,id:"firmware-download",class:"card",style:{background:"var(--surface-2)"}},wg=["href","download"],_g={id:"firmware-log",class:"log"},kg="https://web.esphome.io/?dashboard_install",$g="Download · flash from your own computer",xg=Ee({__name:"FirmwareView",setup(e){const t=pe(null),n=pe(""),s=pe("ota"),o=pe("");let l=0;async function a(E=!1){try{const x=await yt("firmware");t.value=x,(E||!n.value)&&(n.value=x.profiles[0]?.file||"");const S=x.ports||[];s.value.startsWith("/")&&!S.includes(s.value)&&(s.value=S[0]||"usb"),s.value==="usb"&&S.length&&(s.value=S[0])}catch(x){ge(x.message)}}const r=$(()=>t.value?.job?.state==="running"),d=$(()=>!t.value||r.value||!t.value.available||!t.value.profiles?.length),f=$(()=>d.value||s.value==="usb"),c=$(()=>t.value?.ports||[]),y=$(()=>t.value?t.value.available?t.value.job?`${t.value.job.file} · ${t.value.job.action} · ${t.value.job.state}`:"Choose the intended profile, then a USB port, an IP address, or Download.":"The ESPHome CLI is missing. Update the app to 0.2.0.":"Loading…"),b=$(()=>s.value==="download"&&!!n.value&&!!t.value?.downloads?.includes(n.value)),k=$(()=>({href:`api/firmware/profiles/${encodeURIComponent(n.value)}/download`,name:n.value.replace(/\.yaml$/,"")+".factory.bin"}));async function I(E){try{await it("firmware/jobs","POST",{file:n.value,action:E==="validate"?"validate":E==="build"?"build":s.value==="download"?"download":"install",target:s.value==="ota"?o.value.trim():s.value==="download"?"":s.value}),await a()}catch(x){ge(x.message)}}return _n(()=>{a(!0),l=window.setInterval(()=>a(),3e3)}),In(()=>clearInterval(l)),(E,x)=>(p(),h("div",ng,[i("div",sg,[x[7]||(x[7]=Cs('
Firmware & USB
Build and install screen firmware
The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), Wi-Fi / OTA with the hostname, or Download to put the firmware on the screen from your own computer. Check only validates, Build only compiles, Build & install puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.
',1)),i("button",{type:"button",class:"btn quiet",id:"close-firmware",onClick:x[0]||(x[0]=S=>m(Oe)(""))},"← Back")]),i("div",og,[i("div",ig,[i("div",lg,[x[8]||(x[8]=i("label",{class:"f-label",for:"firmware-file"},"ESPHome profile",-1)),we(i("select",{id:"firmware-file","onUpdate:modelValue":x[1]||(x[1]=S=>n.value=S)},[(p(!0),h(U,null,le(t.value?.profiles||[],S=>(p(),h("option",{key:S.file,value:S.file},v(S.file),9,ag))),128))],512),[[vn,n.value]])]),i("div",rg,[x[10]||(x[10]=i("label",{class:"f-label",for:"firmware-port"},"Install to",-1)),we(i("select",{id:"firmware-port","onUpdate:modelValue":x[2]||(x[2]=S=>s.value=S)},[x[9]||(x[9]=i("option",{value:"ota"},"Wi-Fi / OTA",-1)),c.value.length?R("",!0):(p(),h("option",ug,"USB · no board found on the Home Assistant machine yet")),(p(!0),h(U,null,le(c.value,S=>(p(),h("option",{key:S,value:S},v(S),9,cg))),128)),i("option",{value:"download"},v($g))],512),[[vn,s.value]])]),s.value==="ota"?(p(),h("div",dg,[x[11]||(x[11]=i("label",{class:"f-label",for:"firmware-host"},"IP address or hostname",-1)),we(i("input",{id:"firmware-host","onUpdate:modelValue":x[3]||(x[3]=S=>o.value=S),placeholder:n.value?n.value.replace(/\.yaml$/,".local"):"screen-livingroom.local"},null,8,fg),[[Fe,o.value]])])):R("",!0)]),i("div",pg,[i("button",{type:"button",class:"btn quiet",id:"firmware-validate",disabled:d.value,onClick:x[4]||(x[4]=S=>I("validate"))},"Check",8,hg),i("button",{type:"button",class:"btn quiet",id:"firmware-build",disabled:d.value,onClick:x[5]||(x[5]=S=>I("build"))},"Build only",8,vg),i("button",{type:"button",class:"btn primary",id:"firmware-install",disabled:f.value,onClick:x[6]||(x[6]=S=>I("install"))},v(s.value==="download"?"Build & download":"Build & install"),9,mg),r.value?(p(),h("span",yg)):R("",!0)]),i("p",gg,v(y.value),1),b.value?(p(),h("div",bg,[i("a",{class:"btn primary",id:"firmware-download-link",href:k.value.href,download:k.value.name},"Download "+v(k.value.name),9,wg),i("small",null,[x[12]||(x[12]=Z("Put it on the screen with ",-1)),i("a",{href:kg,target:"_blank",rel:"noopener"},"ESPHome Web"),x[13]||(x[13]=Z(" in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.",-1))])])):R("",!0)]),i("pre",_g,v((t.value?.logs||[]).join(`
+`)),1)]))}}),Sg={class:"panel wide alerts",id:"alerts-dialog"},Cg={class:"panel-head"},Tg={key:0,class:"hint"},Ag={class:"alert-hero"},Eg={class:"alert-mock",id:"alert-mock","aria-hidden":"true"},Pg={class:"mdi alert-mock-icon",id:"alert-mock-icon"},Og={class:"alerts-nav","aria-label":"Sections"},Ig=["data-jump","onClick"],Mg={id:"alerts-try",class:"card"},Lg={class:"field"},Dg={value:"all"},Fg=["value","disabled"],Ng={class:"field"},Rg={class:"field"},Hg={class:"field"},Ug={id:"try-icons"},jg=["value"],Bg={class:"field"},Vg=["value"],Wg={class:"field"},Kg={class:"field"},qg={class:"check field"},Yg={class:"actions field wide"},zg=["disabled"],Jg={class:"status-line",id:"try-result",role:"status"},Gg={id:"alerts-screens",class:"card"},Xg={id:"alerts-min-firmware"},Zg={id:"alerts-screen-list",class:"options"},Qg={key:0,class:"hint"},eb={class:"alert-screen-head"},tb={class:"copy-label"},nb=["onClick"],sb={id:"alerts-howto",class:"card"},ob={class:"field"},ib=["value"],lb={key:0,value:""},ab={class:"copy-line"},rb={id:"alerts-example"},ub={id:"alerts-all",class:"card"},cb={id:"alerts-all-event"},db={id:"alerts-all-dismiss"},fb={class:"copy-line"},pb={id:"alerts-all-example"},hb={id:"alerts-fields",class:"card"},vb={class:"table-scroll"},mb={id:"alerts-field-table"},yb={key:0},gb={id:"alerts-icons",class:"card"},bb={class:"chips",id:"alerts-suggested"},wb=["onClick"],_b={class:"mdi"},kb={class:"field"},$b={id:"alerts-icon-groups"},xb={class:"subhead"},Sb={class:"alert-icon-grid"},Cb=["title","onClick"],Tb={class:"mdi"},Ab={key:0},Eb={key:0,class:"hint"},Pb={id:"alerts-colors",class:"card"},Ob={class:"swatches",id:"alerts-swatches"},Ib=["onClick"],Mb={id:"alerts-events",class:"card"},Lb={id:"alerts-event-name"},Db={class:"table-scroll"},Fb={id:"alerts-ending-table"},Nb={class:"copy-line"},Rb={id:"alerts-wait-example"},Hb={id:"alerts-tips",class:"card"},Ub={class:"alerts-list"},jb=Ee({__name:"AlertsView",setup(e){const t=$(()=>u.inventory.alerts),n=bn({screen:"all",title:"Someone is at the door",subtitle:"Door 3, back",icon:"doorbell",color:"orange",button_text:"Coming",timeout:30,flash:!0}),s=pe(!1),o=pe(""),l=$(()=>u.inventory.screens.filter(A=>gn(A)&&A.online));async function a(){s.value=!0,o.value="";try{const{screen:A,...w}=n,T=await ed(A,w),te=A==="all"?`${T.sent} screen${T.sent===1?"":"s"}`:u.inventory.screens.find($e=>$e.id===A)?.name||"the screen";o.value=T.sent?`Sent to ${te}.${T.skipped?` ${T.skipped} skipped (offline or old firmware).`:""}${T.unusable?.length?` Left empty: ${T.unusable.join(", ")}.`:""}`:"No screen could show it: check that a screen is online with firmware "+(t.value?.min_firmware||"0.2.31")+" or newer."}catch(A){o.value=A.message}finally{s.value=!1}}const r=$(()=>u.inventory.icons),d=pe(""),f=pe(""),c=A=>`"${String(A).replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`,y=A=>A.type==="string"?/^[a-z][a-z0-9-]*$/.test(A.example)?A.example:c(A.example):A.example===!0?"true":A.example===!1?"false":String(A.example),b=$(()=>u.inventory.screens.filter(A=>A.alert_action)),k=$(()=>d.value||b.value[0]?.alert_action||""),I=$(()=>{const A=(t.value?.fields||[]).map(w=>` ${w.name}: ${y(w)}`);return`action: ${k.value||"esphome._show_alert"}
+data:
+${A.join(`
+`)}`}),E=$(()=>["# Doorbell: show the alert and wait until someone presses the button.","actions:",` - action: ${k.value||"esphome._show_alert"}`," data:",' title: "Someone is at the door"',' subtitle: "Door 3, back"'," icon: doorbell"," color: orange",' button_text: "Coming"'," timeout: 0"," flash: true"," - wait_for_trigger:"," - trigger: event",` event_type: ${t.value?.event||"esphome.screen_alert"}`," event_data:"," action: ok",' timeout: "00:05:00"'," - if:"," - condition: template",' value_template: "{{ wait.trigger is not none }}"'," then:"," - action: notify.notify"," data:",' message: "Someone is coming to the door."'].join(`
+`)),x=$(()=>{const A=(t.value?.fields||[]).map(T=>` ${T.name}: ${y(T)}`),w=t.value?.camera;return w&&A.push(` # ${w.name}: ${w.example} # a Guition shows its picture on the card`),`event: ${t.value?.broadcast?.show||"esp_screens_show_alert"}
+event_data:
+${A.join(`
+`)}`}),S=$(()=>{if(!t.value||!r.value)return[];const A=f.value.trim().toLowerCase();return[...r.value.groups,{label:"Also available: control and weather icons",icons:t.value.extra_icons}].map(T=>({label:T.label,icons:T.icons.filter(te=>!A||te.name.includes(A)||(te.label||"").toLowerCase().includes(A))})).filter(T=>T.icons.length)}),M=$(()=>t.value?.suggested_icons?.find(A=>A.name==="doorbell")),G=$(()=>t.value?.colors?.find(A=>A.name==="orange")),C={string:"text",int:"number",bool:"on / off"};function V(A){document.getElementById(A)?.scrollIntoView({behavior:"smooth",block:"start"})}const O=[["alerts-try","Try it"],["alerts-screens","Your screens"],["alerts-howto","In an automation"],["alerts-all","All screens"],["alerts-fields","The fields"],["alerts-icons","Icons"],["alerts-colors","Colors"],["alerts-behaviour","Behavior"],["alerts-events","Events"],["alerts-tips","Tips"]];return(A,w)=>(p(),h("div",Sg,[i("div",Cg,[w[15]||(w[15]=i("div",{class:"tx"},[i("span",{class:"eyebrow"},"Alerts · cheatsheet"),i("h1",null,"A full-screen notification, from an automation."),i("p",null,[Z("Every screen has the "),i("code",null,"show_alert"),Z(" action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.")])],-1)),i("button",{type:"button",class:"btn quiet",id:"close-alerts",onClick:w[0]||(w[0]=T=>m(Oe)(""))},"← Back")]),t.value?(p(),h(U,{key:1},[i("div",Ag,[i("div",Eg,[i("div",{class:"alert-mock-card",style:Pe(G.value?{background:G.value.color}:void 0)},[i("span",Pg,v(M.value?m(ke)(M.value.cp):""),1),w[16]||(w[16]=i("strong",{id:"alert-mock-title"},"Someone is at the door",-1)),w[17]||(w[17]=i("span",{id:"alert-mock-subtitle"},"Door 3, back",-1)),w[18]||(w[18]=i("span",{class:"fake",id:"alert-mock-button"},"Coming",-1))],4)]),i("nav",Og,[(p(),h(U,null,le(O,([T,te])=>i("button",{key:T,type:"button","data-jump":T,onClick:$e=>V(T)},v(te),9,Ig)),64))])]),i("section",Mg,[w[28]||(w[28]=i("h2",null,"Try it",-1)),w[29]||(w[29]=i("p",null,"Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.",-1)),i("form",{class:"try-grid",onSubmit:dt(a,["prevent"])},[i("div",Lg,[w[19]||(w[19]=i("label",{class:"f-label",for:"try-screen"},"Screen",-1)),we(i("select",{id:"try-screen","onUpdate:modelValue":w[1]||(w[1]=T=>n.screen=T)},[i("option",Dg,"All screens ("+v(l.value.length)+" ready)",1),(p(!0),h(U,null,le(m(u).inventory.screens,T=>(p(),h("option",{key:T.id,value:T.id,disabled:!m(gn)(T)||!T.online},v(T.name)+v(m(gn)(T)&&T.online?"":" · not ready"),9,Fg))),128))],512),[[vn,n.screen]])]),i("div",Ng,[w[20]||(w[20]=i("label",{class:"f-label",for:"try-title"},"Title",-1)),we(i("input",{id:"try-title","onUpdate:modelValue":w[2]||(w[2]=T=>n.title=T),maxlength:"64"},null,512),[[Fe,n.title]])]),i("div",Rg,[w[21]||(w[21]=i("label",{class:"f-label",for:"try-subtitle"},"Subtitle",-1)),we(i("input",{id:"try-subtitle","onUpdate:modelValue":w[3]||(w[3]=T=>n.subtitle=T),maxlength:"240"},null,512),[[Fe,n.subtitle]])]),i("div",Hg,[w[22]||(w[22]=i("label",{class:"f-label",for:"try-icon"},"Icon",-1)),we(i("input",{id:"try-icon","onUpdate:modelValue":w[4]||(w[4]=T=>n.icon=T),list:"try-icons",placeholder:"doorbell"},null,512),[[Fe,n.icon]]),i("datalist",Ug,[(p(!0),h(U,null,le(t.value.suggested_icons,T=>(p(),h("option",{key:T.name,value:T.name},v(T.label||T.name),9,jg))),128))])]),i("div",Bg,[w[24]||(w[24]=i("label",{class:"f-label",for:"try-color"},"Color",-1)),we(i("select",{id:"try-color","onUpdate:modelValue":w[5]||(w[5]=T=>n.color=T)},[w[23]||(w[23]=i("option",{value:""},"White (default)",-1)),(p(!0),h(U,null,le(t.value.colors,T=>(p(),h("option",{key:T.name,value:T.name},v(T.label),9,Vg))),128))],512),[[vn,n.color]])]),i("div",Wg,[w[25]||(w[25]=i("label",{class:"f-label",for:"try-button"},"Button text",-1)),we(i("input",{id:"try-button","onUpdate:modelValue":w[6]||(w[6]=T=>n.button_text=T),maxlength:"16"},null,512),[[Fe,n.button_text]])]),i("div",Kg,[w[26]||(w[26]=i("label",{class:"f-label",for:"try-timeout"},"Timeout (seconds, 0 waits for the button)",-1)),we(i("input",{id:"try-timeout","onUpdate:modelValue":w[7]||(w[7]=T=>n.timeout=T),type:"number",min:"0",max:"86400"},null,512),[[Fe,n.timeout,void 0,{number:!0}]])]),i("label",qg,[we(i("input",{type:"checkbox",id:"try-flash","onUpdate:modelValue":w[8]||(w[8]=T=>n.flash=T)},null,512),[[hc,n.flash]]),w[27]||(w[27]=i("span",null,[Z("Blink the backlight"),i("small",null,"Four times when the alert arrives.")],-1))]),i("div",Yg,[i("button",{type:"submit",class:"btn primary",id:"try-send",disabled:s.value||!l.value.length},v(s.value?"Sending…":"Send alert"),9,zg),i("span",Jg,v(o.value),1)])],32)]),i("section",Gg,[w[32]||(w[32]=i("h2",null,"Your screens",-1)),i("p",null,[w[30]||(w[30]=Z("Every screen has its own action, with the device name in it. From firmware ",-1)),i("code",Xg,v(t.value.min_firmware),1),w[31]||(w[31]=Z("; update an older screen with its Update button under Screens.",-1))]),i("div",Zg,[m(u).inventory.screens.length?R("",!0):(p(),h("p",Qg,"No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.")),(p(!0),h(U,null,le(m(u).inventory.screens,T=>(p(),h("div",{key:T.id,class:"alert-screen"},[i("div",eb,[i("strong",null,v(T.name),1),i("span",{class:ie(["chip",m(nn)(m(ls)(T),t.value.min_firmware)&&T.alert_action?"good":"update"])},v(m(nn)(m(ls)(T),t.value.min_firmware)&&T.alert_action?`● firmware ${T.firmware}`:T.alert_action?`Update needed · firmware ${T.firmware||"unknown"}`:"Device name unknown · update the screen"),3)]),(p(!0),h(U,null,le([["Show alert",T.alert_action],["Dismiss alert",T.dismiss_action]],([te,$e])=>(p(),h("div",{key:te,class:"copy-line"},[i("span",tb,v(te),1),i("code",null,v($e||"esphome._show_alert"),1),$e?(p(),h("button",{key:0,type:"button",class:"btn quiet mini",onClick:Me=>m(ft)($e,void 0,"Action name")},"Copy",8,nb)):R("",!0)]))),128))]))),128))])]),i("section",sb,[w[34]||(w[34]=Cs('
In an automation
Add action and search for ESPHome. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node device name".
Fill in the fields. Home Assistant asks for all seven; leave what you don't use empty ("", 0, off).
Prefer YAML? Choose Edit in YAML on the action and paste the example below.
',2)),i("div",ob,[w[33]||(w[33]=i("label",{class:"f-label",for:"alerts-example-screen"},"Example for",-1)),we(i("select",{id:"alerts-example-screen","onUpdate:modelValue":w[9]||(w[9]=T=>d.value=T),style:{"max-width":"360px"}},[(p(!0),h(U,null,le(b.value,T=>(p(),h("option",{key:T.id,value:T.alert_action},v(T.name),9,ib))),128)),b.value.length?R("",!0):(p(),h("option",lb,"a screen (not paired yet)"))],512),[[vn,d.value]])]),i("div",ab,[i("pre",rb,v(I.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-example-copy",onClick:w[10]||(w[10]=T=>m(ft)(I.value,void 0,"YAML"))},"Copy YAML")])]),i("section",ub,[w[42]||(w[42]=i("h2",null,"All screens at once",-1)),i("p",null,[w[35]||(w[35]=Z("Fire the event ",-1)),i("code",cb,v(t.value.broadcast?.show||"esp_screens_show_alert"),1),w[36]||(w[36]=Z(" with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. ",-1)),i("code",db,v(t.value.broadcast?.dismiss||"esp_screens_dismiss_alert"),1),w[37]||(w[37]=Z(" clears it everywhere. In an automation, add the ",-1)),w[38]||(w[38]=i("b",null,"Event",-1)),w[39]||(w[39]=Z(" action or paste the example under ",-1)),w[40]||(w[40]=i("b",null,"Edit in YAML",-1)),w[41]||(w[41]=Z(". Leave out the fields you don't use. ESP Screens has to be running for this.",-1))]),i("div",fb,[i("pre",pb,v(x.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-all-copy",onClick:w[11]||(w[11]=T=>m(ft)(x.value,void 0,"YAML"))},"Copy YAML")])]),i("section",hb,[w[46]||(w[46]=i("h2",null,"The fields",-1)),w[47]||(w[47]=i("p",null,"Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).",-1)),i("div",vb,[i("table",mb,[w[45]||(w[45]=i("tr",null,[i("th",null,"Field"),i("th",null,"Type"),i("th",null,"What it does"),i("th",null,"Example"),i("th",null,"Limit")],-1)),(p(!0),h(U,null,le(t.value.fields,T=>(p(),h("tr",{key:T.name},[i("td",null,[i("code",null,v(T.name),1),i("small",null,v(T.label),1)]),i("td",null,v(C[T.type]||T.type),1),i("td",null,v(T.help),1),i("td",null,[i("code",null,v(typeof T.example=="string"?T.example:String(T.example)),1)]),i("td",null,v(t.value.limits.cyd[T.name]?`CYD ${t.value.limits.cyd[T.name]} · Guition ${t.value.limits.guition[T.name]} bytes`:T.type==="int"?"0 to 86400 s":"—"),1)]))),128)),t.value.camera?(p(),h("tr",yb,[i("td",null,[i("code",null,v(t.value.camera.name),1),i("small",null,v(t.value.camera.label),1)]),w[43]||(w[43]=i("td",null,"entity",-1)),i("td",null,v(t.value.camera.help),1),i("td",null,[i("code",null,v(t.value.camera.example),1)]),w[44]||(w[44]=i("td",null,"Guition, firmware 0.2.57+",-1))])):R("",!0)])])]),i("section",gb,[w[49]||(w[49]=i("h2",null,"Icons",-1)),w[50]||(w[50]=i("p",null,[Z("Fill in the name in "),i("code",null,"icon"),Z(", for example "),i("code",null,"doorbell"),Z(". "),i("code",null,"mdi:doorbell"),Z(" and the hex codepoint ("),i("code",null,"F12E6"),Z(") also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.")],-1)),w[51]||(w[51]=i("p",{class:"subhead"},"Handy for notifications",-1)),i("div",bb,[(p(!0),h(U,null,le(t.value.suggested_icons,T=>(p(),h("button",{key:T.name,type:"button",class:"chip",onClick:te=>m(ft)(T.name,void 0,"Icon name")},[i("span",_b,v(m(ke)(T.cp)),1),i("code",null,v(T.name),1)],8,wb))),128))]),i("div",kb,[w[48]||(w[48]=i("label",{class:"f-label",for:"alerts-icon-search"},"Search for an icon",-1)),we(i("input",{id:"alerts-icon-search","onUpdate:modelValue":w[12]||(w[12]=T=>f.value=T),type:"search",placeholder:"Search by name or description…",autocomplete:"off"},null,512),[[Fe,f.value]])]),i("div",$b,[(p(!0),h(U,null,le(S.value,T=>(p(),h("div",{key:T.label,class:"field"},[i("p",xb,v(T.label)+" · "+v(T.icons.length),1),i("div",Sb,[(p(!0),h(U,null,le(T.icons,te=>(p(),h("button",{key:te.name,type:"button",class:"alert-icon",title:`Copy ${te.name}`,onClick:$e=>m(ft)(te.name,void 0,"Icon name")},[i("span",Tb,v(m(ke)(te.cp)),1),i("code",null,v(te.name),1),te.label?(p(),h("small",Ab,v(te.label),1)):R("",!0)],8,Cb))),128))])]))),128)),S.value.length?R("",!0):(p(),h("p",Eb,'No icon for "'+v(f.value)+'". Unknown names show the warning triangle ('+v(t.value.fallback_icon)+").",1))])]),i("section",Pb,[w[53]||(w[53]=i("h2",null,"Colors",-1)),w[54]||(w[54]=i("p",null,[Z("Fill in the name in "),i("code",null,"color"),Z(". These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.")],-1)),i("div",Ob,[i("button",{type:"button",class:"swatch",onClick:w[13]||(w[13]=T=>m(ft)("",void 0,"Empty color"))},[...w[52]||(w[52]=[i("i",{style:{background:"#ffffff"}},null,-1),i("span",null,[i("code",null,"empty"),i("small",null,"White (default)")],-1)])]),(p(!0),h(U,null,le(t.value.colors,T=>(p(),h("button",{key:T.name,type:"button",class:"swatch",onClick:te=>m(ft)(T.name,void 0,"Color name")},[i("i",{style:Pe({background:T.color})},null,4),i("span",null,[i("code",null,v(T.name),1),i("small",null,v(T.label)+" · "+v(T.color),1)])],8,Ib))),128))])]),w[75]||(w[75]=Cs('
Behavior
Wakes the screen. An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.
On until the button. As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.
Timeout.timeout: 0 waits for the button, however long that takes. With timeout: 60 the card disappears after a minute, but the button closes it sooner.
Blinking.flash: true blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.
Replacing. A new alert replaces the current one; there's no stack. The replaced alert reports itself as replaced.
Closing remotely.dismiss_alert clears the card, for example if the door is already open. Without an alert, that action does nothing.
Tapping beside the card does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.
',1)),i("section",Mb,[w[66]||(w[66]=i("h2",null,"Events",-1)),i("p",null,[w[55]||(w[55]=Z("Every end fires the event ",-1)),i("code",Lb,v(t.value.event),1),w[56]||(w[56]=Z(" with ",-1)),w[57]||(w[57]=i("code",null,"action",-1)),w[58]||(w[58]=Z(", ",-1)),w[59]||(w[59]=i("code",null,"title",-1)),w[60]||(w[60]=Z(", ",-1)),w[61]||(w[61]=i("code",null,"screen",-1)),w[62]||(w[62]=Z(" (the device name) and the ",-1)),w[63]||(w[63]=i("code",null,"device_id",-1)),w[64]||(w[64]=Z(" that Home Assistant adds. Visible under Settings → Tools → Events.",-1))]),i("div",Db,[i("table",Fb,[w[65]||(w[65]=i("tr",null,[i("th",null,"action"),i("th",null,"When")],-1)),(p(!0),h(U,null,le(t.value.endings,T=>(p(),h("tr",{key:T.action},[i("td",null,[i("code",null,v(T.action),1)]),i("td",null,v(T.label),1)]))),128))])]),w[67]||(w[67]=i("p",{class:"subhead"},"Waiting until someone presses the button",-1)),i("div",Nb,[i("pre",Rb,v(E.value),1),i("button",{type:"button",class:"btn quiet mini",id:"alerts-wait-copy",onClick:w[14]||(w[14]=T=>m(ft)(E.value,void 0,"YAML"))},"Copy YAML")])]),i("section",Hb,[w[74]||(w[74]=i("h2",null,"Tips",-1)),i("ul",Ub,[w[72]||(w[72]=Cs("
Use a color per kind of notification: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.
Keep the title short; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.
A custom button text turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.
For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with dismiss_alert.
",4)),i("li",null,[w[68]||(w[68]=Z("Every screen? Fire ",-1)),i("code",null,v(t.value.broadcast?.show||"esp_screens_show_alert"),1),w[69]||(w[69]=Z(" once (see All screens at once). The screens still work independently and each reports with its own ",-1)),w[70]||(w[70]=i("code",null,"screen",-1)),w[71]||(w[71]=Z(".",-1))]),w[73]||(w[73]=i("li",null,[Z("Rather "),i("b",null,"ask Claude"),Z(" for the automation? Install the ESP Screens skill under Settings → Claude.")],-1))])])],64)):(p(),h("p",Tg,"The cheatsheet is still loading; one moment."))]))}}),Bb={class:"panel",id:"override-dialog"},Vb={class:"panel-head"},Wb={class:"tx"},Kb={id:"override-title"},qb={class:"notice"},Yb={class:"mdi"},zb={class:"file-row"},Jb={id:"override-file"},Gb={class:"yaml-editor",id:"yaml-editor-wrap"},Xb={class:"actions"},Zb={id:"override-count",class:"hint"},Qb={class:"actions"},e1=["disabled"],t1=["disabled"],n1=`# Hardware-specific changes for this screen.
+# This file is kept when the shared firmware package updates.
+# Do not add esphome:, api:, ota:, wifi: or packages: here.
+
+# Example: a CYD with the ST7789V display controller.
+# "!extend" changes the display the shared package already defines;
+# a bare id would add a second, incomplete display and fail the build.
+display:
+ - id: !extend my_display
+ model: ST7789V
+`,s1=Ee({__name:"OverrideView",setup(e){const t=$(()=>u.overrideProfile),n=pe(""),s=pe(""),o=pe(!1),l=pe("Loading…"),a=pe(""),r=pe(!1),d=pe(null),f=pe(null),c=$(()=>n.value.split(`
+`).length),y=$(()=>Array.from({length:c.value},(O,A)=>A+1).join(`
+`)),b=$(()=>`${n.value.length} characters · ${c.value} line${c.value===1?"":"s"}`);let k=!0;function I(O,A=""){l.value=O,a.value=A}async function E(){if(!t.value){I("No ESPHome profile was found for this screen.","error");return}I("Loading…");try{const O=await yt(`firmware/profiles/${encodeURIComponent(t.value)}/override`);s.value=O.override_file,o.value=!!O.attached,n.value=O.exists&&O.content!==`{}
+`?O.content:"",I(O.attached?"Changes here apply on the next build.":"Save once to attach this file to the profile.")}catch(O){I(O.message,"error")}}async function x(O=!1){if(!(!t.value||r.value)){r.value=!0,I("Checking and saving…");try{const A=await it(`firmware/profiles/${encodeURIComponent(t.value)}/override`,"PUT",{content:n.value});if(s.value=A.override_file,o.value=!0,!O){I("Saved. The override is kept during firmware updates.","ok"),ge("Override YAML saved.");return}I("Saved. ESPHome is checking the complete profile…"),await it("firmware/jobs","POST",{file:t.value,action:"validate"}),S()}catch(A){I(A.message,"error")}finally{r.value=!1}}}function S(){const O=Date.now(),A=t.value,w=async()=>{if(!(!k||t.value!==A))try{const T=await yt("firmware"),te=T.job;if(te&&te.file===A&&te.state==="running")I(`ESPHome is checking the profile… ${te.stage||""}`.trim());else if(te&&te.file===A&&te.state==="success"){I("The complete profile is valid. Safe to build and install.","ok");return}else if(te&&te.file===A&&te.state==="failed"){const $e=(T.logs||[]).filter(Me=>/error|failed/i.test(Me)).pop();I($e||"ESPHome rejected the complete profile. See Firmware & USB for the full log.","error");return}Date.now()-O<72e5&&setTimeout(w,1200)}catch(T){I(T.message,"error")}};w()}function M(){(!n.value.trim()||confirm("Replace the current text with the example?"))&&(n.value=n1,I("Example loaded. Save it when you are ready."),d.value?.focus())}function G(){(!n.value.trim()||confirm("Clear the local override?"))&&(n.value="",I("The override will be empty after you save."),d.value?.focus())}function C(O){const A=d.value;if(O.key==="Tab"){O.preventDefault();const w=A.selectionStart,T=A.selectionEnd;A.setRangeText(" ",w,T,"end"),n.value=A.value}else(O.ctrlKey||O.metaKey)&&O.key.toLowerCase()==="s"&&(O.preventDefault(),x(!1))}function V(){f.value&&d.value&&(f.value.scrollTop=d.value.scrollTop)}return _n(E),In(()=>{k=!1}),(O,A)=>(p(),h("div",Bb,[i("div",Vb,[i("div",Wb,[A[4]||(A[4]=i("span",{class:"eyebrow"},"Advanced · local override",-1)),i("h1",Kb,"Override YAML"+v(m(u).overrideFriendly?` · ${m(u).overrideFriendly}`:""),1),A[5]||(A[5]=i("p",null,"This small file is yours. It is loaded after the shared screen package and stays in place when ESP Screen Manager updates. Change hardware-specific details here, such as a display model.",-1))]),i("button",{type:"button",class:"btn quiet",id:"close-override",onClick:A[0]||(A[0]=w=>m(Oe)(""))},"← Back")]),i("div",qb,[i("span",Yb,v(m(ke)("F0493")),1),A[6]||(A[6]=i("div",null,[i("strong",null,"What is safe to change?"),i("span",null,"Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.")],-1))]),i("div",zb,[i("code",Jb,v(s.value||t.value||""),1),i("span",{id:"override-state",class:ie(["chip",{good:o.value}])},v(o.value?"Active":"Ready to attach"),3)]),i("div",Gb,[i("div",{ref_key:"gutter",ref:f,class:"yaml-gutter",id:"override-gutter","aria-hidden":"true"},v(y.value),513),we(i("textarea",{ref_key:"editor",ref:d,id:"override-editor","onUpdate:modelValue":A[1]||(A[1]=w=>n.value=w),spellcheck:"false",autocapitalize:"off",autocomplete:"off",autocorrect:"off","aria-label":"Local override YAML",placeholder:`# Example
+display:
+ - id: !extend my_display
+ model: ST7789V`,onKeydown:C,onScroll:V},null,544),[[Fe,n.value]])]),i("div",Xb,[i("button",{type:"button",class:"btn quiet mini",id:"override-example",onClick:M},"Use example"),i("button",{type:"button",class:"btn quiet mini",id:"override-empty",onClick:G},"Clear"),i("span",Zb,v(b.value),1)]),i("p",{id:"override-status",class:ie(["status-line",a.value]),role:"status"},v(l.value),3),i("div",Qb,[i("button",{type:"button",class:"btn quiet",id:"override-check",disabled:r.value||!t.value,onClick:A[2]||(A[2]=w=>x(!0))},"Save & check",8,e1),i("button",{type:"button",class:"btn primary",id:"override-save",disabled:r.value||!t.value,onClick:A[3]||(A[3]=w=>x(!1))},"Save override",8,t1)])]))}}),o1={class:"main"},i1=Ee({__name:"App",setup(e){const t=$(()=>rt.value==="#settings"?hy:rt.value==="#new-screen"?tg:rt.value==="#firmware"?xg:rt.value==="#alerts"?jb:rt.value==="#override"?s1:lt.value&&u.layout?Km:zm);function n(s){(s.metaKey||s.ctrlKey)&&s.key.toLowerCase()==="k"&&(s.preventDefault(),u.palette=!u.palette)}return _n(()=>document.addEventListener("keydown",n)),In(()=>document.removeEventListener("keydown",n)),(s,o)=>(p(),h("div",{class:ie(["app",{dragging:m(u).drag.active}])},[_e(jd),i("main",o1,[(p(),Xe(su(t.value)))]),_e(Vd),_e(Qd)],2))}});_c(i1).mount("#app");vd();
diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html
index 39720db..fcf3fa1 100644
--- a/screen_manager/app/static/index.html
+++ b/screen_manager/app/static/index.html
@@ -5,8 +5,8 @@
ESP Screens
-
-
+
+
diff --git a/screen_manager/app/updates.py b/screen_manager/app/updates.py
index 4d13615..cb714c9 100644
--- a/screen_manager/app/updates.py
+++ b/screen_manager/app/updates.py
@@ -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
diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml
index 773e100..adcc8a3 100644
--- a/screen_manager/config.yaml
+++ b/screen_manager/config.yaml
@@ -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
diff --git a/tests/test_alert_broadcast.py b/tests/test_alert_broadcast.py
index 3e90beb..15b6d69 100644
--- a/tests/test_alert_broadcast.py
+++ b/tests/test_alert_broadcast.py
@@ -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)
diff --git a/tests/test_alerts_reference.py b/tests/test_alerts_reference.py
index 46b5e05..97d6c69 100644
--- a/tests/test_alerts_reference.py
+++ b/tests/test_alerts_reference.py
@@ -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)
diff --git a/tests/test_camera.py b/tests/test_camera.py
index 760ee1b..10115a7 100644
--- a/tests/test_camera.py
+++ b/tests/test_camera.py
@@ -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))])
diff --git a/tests/test_cyd_ui.cpp b/tests/test_cyd_ui.cpp
index b021e17..da41f1b 100644
--- a/tests/test_cyd_ui.cpp
+++ b/tests/test_cyd_ui.cpp
@@ -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);
diff --git a/tests/test_editor_live.py b/tests/test_editor_live.py
index 9ed0788..ef1680d 100644
--- a/tests/test_editor_live.py
+++ b/tests/test_editor_live.py
@@ -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):
diff --git a/tests/test_extended.py b/tests/test_extended.py
index 796c788..f6bbd9e 100644
--- a/tests/test_extended.py
+++ b/tests/test_extended.py
@@ -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)
diff --git a/tests/test_firmware_download.py b/tests/test_firmware_download.py
index a54d850..5744cc9 100644
--- a/tests/test_firmware_download.py
+++ b/tests/test_firmware_download.py
@@ -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
diff --git a/tests/test_full_page_tiles.py b/tests/test_full_page_tiles.py
index 6159cca..ddd8fac 100644
--- a/tests/test_full_page_tiles.py
+++ b/tests/test_full_page_tiles.py
@@ -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')]})
diff --git a/tests/test_known_firmware.py b/tests/test_known_firmware.py
new file mode 100644
index 0000000..f08be32
--- /dev/null
+++ b/tests/test_known_firmware.py
@@ -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, " (ESPHome )" 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()
diff --git a/tests/test_overrides.py b/tests/test_overrides.py
index 2b2ad1b..3d4b386 100644
--- a/tests/test_overrides.py
+++ b/tests/test_overrides.py
@@ -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'}])
diff --git a/tests/test_page_assets.py b/tests/test_page_assets.py
index 480a35f..4d0dcca 100644
--- a/tests/test_page_assets.py
+++ b/tests/test_page_assets.py
@@ -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()
diff --git a/tests/test_page_tile_copies.py b/tests/test_page_tile_copies.py
new file mode 100644
index 0000000..89c3e39
--- /dev/null
+++ b/tests/test_page_tile_copies.py
@@ -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()
diff --git a/tests/test_positions.py b/tests/test_positions.py
index 7f07f0e..2d448ef 100644
--- a/tests/test_positions.py
+++ b/tests/test_positions.py
@@ -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
diff --git a/tests/test_release_lint.py b/tests/test_release_lint.py
new file mode 100644
index 0000000..e44ee76
--- /dev/null
+++ b/tests/test_release_lint.py
@@ -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/ 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()
diff --git a/tests/test_rich_cards.py b/tests/test_rich_cards.py
index 8e0add6..ff6b654 100644
--- a/tests/test_rich_cards.py
+++ b/tests/test_rich_cards.py
@@ -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
diff --git a/tests/test_rotation.py b/tests/test_rotation.py
index c43000b..f921800 100644
--- a/tests/test_rotation.py
+++ b/tests/test_rotation.py
@@ -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
diff --git a/tests/test_runtime_model.cpp b/tests/test_runtime_model.cpp
index 376cf14..cbf0d9c 100644
--- a/tests/test_runtime_model.cpp
+++ b/tests/test_runtime_model.cpp
@@ -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 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 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;
diff --git a/tests/test_save_limits.py b/tests/test_save_limits.py
new file mode 100644
index 0000000..c17adb9
--- /dev/null
+++ b/tests/test_save_limits.py
@@ -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()
diff --git a/tests/test_screen_owned_settings.py b/tests/test_screen_owned_settings.py
index 2521518..5e6ba63 100644
--- a/tests/test_screen_owned_settings.py
+++ b/tests/test_screen_owned_settings.py
@@ -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)
diff --git a/tests/test_settings_page.py b/tests/test_settings_page.py
index 6896c63..34f31cf 100644
--- a/tests/test_settings_page.py
+++ b/tests/test_settings_page.py
@@ -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)
diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py
new file mode 100644
index 0000000..5ffa989
--- /dev/null
+++ b/tests/test_shutdown.py
@@ -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()
diff --git a/tests/test_tile_controls.py b/tests/test_tile_controls.py
index 4cb6963..d596fd5 100644
--- a/tests/test_tile_controls.py
+++ b/tests/test_tile_controls.py
@@ -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
diff --git a/tests/test_tile_icons.py b/tests/test_tile_icons.py
index a2d3ec4..465a75d 100644
--- a/tests/test_tile_icons.py
+++ b/tests/test_tile_icons.py
@@ -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
diff --git a/tests/test_tile_palette.py b/tests/test_tile_palette.py
index a89722c..397fb3b 100644
--- a/tests/test_tile_palette.py
+++ b/tests/test_tile_palette.py
@@ -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
diff --git a/tests/test_twenty_tiles.py b/tests/test_twenty_tiles.py
index 902ec59..ae7ae4f 100644
--- a/tests/test_twenty_tiles.py
+++ b/tests/test_twenty_tiles.py
@@ -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
diff --git a/tools/check.sh b/tools/check.sh
new file mode 100755
index 0000000..acbe231
--- /dev/null
+++ b/tools/check.sh
@@ -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
diff --git a/web/package.json b/web/package.json
index 6b15d33..bee03f1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/src/api.ts b/web/src/api.ts
index 4a57f92..381294e 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -16,9 +16,12 @@ export async function api(path: string, options: RequestInit = {}): Promise 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"],
{{ 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." }}
{{ matches.length }} results. Keep typing to narrow it down.