diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e00d309f..fa951deb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,6 +15,15 @@ concurrency: cancel-in-progress: true jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: { node-version: 22 } + - name: Validate public documentation + run: npm run docs:check -- --external + provenance: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 996dfb19..dca9d594 100755 --- a/README.md +++ b/README.md @@ -1,379 +1,157 @@ -# 🏠 House Plan — interactive floor plan card for Home Assistant +# 🏠 House Plan — a live home map for Home Assistant [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) [![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases) -[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers) [![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech) [![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan) -**Turn Home Assistant into a live, interactive map of your home.** Upload or draw -a floor plan, outline the rooms with your mouse — and every smart device appears -in its real place: live states, tap-to-toggle lights, temperature and humidity per -room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall -tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives -right on your Lovelace dashboard. +📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)** -> **Use a desktop computer to edit plans.** View and kiosk are fully supported -> on phones and tablets. The editors are designed primarily for a desktop -> browser with a mouse and keyboard; individual editing operations on touch -> devices may be awkward, limited, or unavailable. + -![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif) +## Your whole home at a glance -> ### 🚀 Try it live — no install needed -> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant -> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything: -> toggle lights, open the editors, break things. The stand resets itself to a -> pristine state every hour. +House Plan turns Home Assistant into a live map of your home. Upload a plan or +draw rooms directly on the dashboard, bind them to Home Assistant areas, and +the area's devices appear automatically. You can immediately see where a light +is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak +sensor has fired. -🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan) +![Synthetic home in View mode with rooms, devices, light and climate](docs/images/01-view-desktop.png) -**Feature highlights** +Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor. +Plan data and device positions live on the Home Assistant server and stay in +sync across screens. -- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run - past: draw and place devices anywhere, pan at any zoom, zoom out to see - everything, and let one tap fit the whole plan back on screen. -- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms, - virtual walls and a visual decor layer, all drawn with clicks; room resize - by dragging walls, with live lengths and areas as you drag; smart - alignment guides and a live ruler in real meters/feet. -- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into - place and pull a corner to size it, with its real size in metres shown as - you drag, so the drawing and the photo of your plan finally line up. -- 💡 **Lights toggle on click** out of the box; wall-switch markers can control - whole groups of lights (works for dumb switches and stateless remotes too). -- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly - the floor it can see: through doorways and open boundaries, stopped by walls, - columns and partitions, which cast real shadows. -- ☀️ **The sun on the plan** — set the compass and the backdrop lives with - the day (white noon → golden hour → deep night), while windows on exterior - walls cast real wedges of sunlight into the rooms; optional cloud cover - from a weather entity. -- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or - stops a cover, and the icon itself morphs between open and closed while a - soft ring pulses as it travels. -- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count; - comfort-range temperature fills, per-room signal heatmap. -- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an - explicit button, never an accidental tap. -- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors, - auto-carousel, per-screen icon sizes. -- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck - drives the plan in real time, pouring its path out from under itself; - current and previous cleanup runs are recorded server-side. Calibration is - one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic - source picker also covers registry-less map cameras without silently - rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack - dreame-vacuum and Valetudo. -- 🔔 New devices appear automatically with a red “new” dot; the layout is stored - **server-side** — one shared plan for every user and screen, synced live. +> **Edit on a desktop computer.** View and kiosk are fully supported on phones +> and tablets. The editors are designed primarily for a mouse and keyboard; +> individual touch editing operations may be awkward or unavailable. See the +> exact [touch support contract](docs/TOUCH-SUPPORT.md). ---- + -## What it is and why +## What House Plan provides -House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open. +- **Live state and safe actions.** Lights and other safe devices can toggle from + the plan; a lock cannot be opened by an accidental plan tap. +- **Three built-in editors.** Plan creates rooms, walls and openings; Device + places and configures markers; Background adds lines, labels and furniture. +- **Area-aware rooms.** New devices appear automatically, while room cards can + show temperature, humidity, light state and average LQI. +- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle + backdrop and sunlight through windows. +- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks; + a robot can show its position, dock and travelled path. +- **Several floors and screens.** Space tabs, swipe navigation, local viewport, + and a separate initial floor for each card. +- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon + sizes saved for that display. -This is convenient when: +![The same synthetic home in touch View mode](docs/images/02-view-touch.png) -- you have many devices and lists are awkward to use; -- you need to grasp the state of the house "at a glance"; -- you want to give access to family members — anyone can figure out a picture; -- you want a beautiful overview screen for a wall-mounted tablet. + -The integration consists of two parts that are installed together: +## Your first working room -- **the Lovelace card** `houseplan-card` — the interactive plan itself; -- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices. +1. Install the integration and add the card to a dashboard. +2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image, + or choose no image and draw the plan by hand. +3. In Plan, select **Room outline**, place vertices, and click the first point to + close the outline. +4. Name the room and bind it to a Home Assistant area. Use “No area” for a room + that has no devices. +5. Open Device: devices from the bound area are already placed; drag their + markers to the correct positions. +6. Optionally use Background for lines, text and furniture. +7. Return to View. The plan now displays live state and accepts safe actions. ---- +![Creating the first space](docs/images/03-space-create.png) -## How it differs from alternatives +![Closing a room outline on its first point](docs/images/04-room-contour-close.png) -A house plan in Home Assistant is usually built with `picture-elements`, -`ha-floorplan`, or newer GUI cards that draw walls and furniture in the -dashboard. Those either lock you into YAML/SVG, or store the plan in the -Lovelace card config. House Plan is a **shared live map** backed by a Home -Assistant integration: +![A selected partition and the Plan context tray](docs/images/05-plan-context-tray.png) -| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) | -|---|---|---|---| -| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture | -| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing | -| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas | -| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) | -| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML | -| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card | -| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas | +![Device settings with binding provenance and the exact action result](docs/images/06-device-editor.png) -**One sentence:** House Plan is the shared, area-aware live map of your home — -not a general-purpose CAD package. Its Background editor covers practical -decor, labels and furniture; if you need unrestricted architectural drafting, -a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps -every tablet on the same live layout. +![Live presentation preview for the same device](docs/images/06-device-display-preview.png) -Key advantages in short: +Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md). +The [Background editor contract](docs/DECOR-EDITOR.md) and +[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems. -- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks. -- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves. -- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual. -- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time. - Icon colors follow one principle — **yellow means the device is doing its main job right now**: - a light is shining, a socket is powering, a fan is spinning, a vacuum is - cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations, - a reported work action is authoritative; when an integration exposes only its enabled HVAC mode, - that mode is the best available fallback. Orange = open / unlocked. - A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow - spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard. - A translucent icon = unavailable. Dark = idle. -- **A coherent visual Background editor.** Draw lines/shapes, place labels and - furniture, edit physical styles and transform every object with the same - selection model. The plan image has its own move/resize/rotate tool, numeric - properties and shared Undo/Redo. -- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale. - ---- - -## Wall tablet / TV (kiosk mode) - -Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true` -(or tick "Wall device (kiosk) mode" in the card editor): - -```yaml -type: custom:houseplan-card -kiosk: true -cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs) -``` - -No header, no editors — just the live plan. Swipe to change floors (at 1:1), -pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to -tune icon and text sizes for THIS screen (saved per device). To hide Home -Assistant's own header use the companion app's kiosk settings or the -[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin. + ## Installation -One click if you already run HACS: +### HACS -[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration) +[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration) +1. In HACS open **⋮ → Custom repositories**. +2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**. +3. Install House Plan and restart Home Assistant. +4. Open **Settings → Devices & services → Add integration → House Plan**. -### Via HACS (recommended) +The card is registered automatically. If you manage Lovelace resources +manually, use the URL served by the integration: -1. Open **HACS → menu (⋮) → Custom repositories**. -2. Paste the URL of this repository, set the category to **Integration**, and click **Add**. -3. Find **House Plan** in the list, install it and **restart Home Assistant**. -4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**. +```yaml +resources: + - url: /houseplan_files/houseplan-card.js + type: module +``` -The card is registered automatically — no need to add a Lovelace resource manually. +Do not use the on-disk path inside `custom_components`; Home Assistant does not +serve that path as a JavaScript module. -> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace -> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*: -> -> ```yaml -> resources: -> - url: /houseplan_files/houseplan-card.js -> type: module -> ``` -> -> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file -> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error -> and the element never registers). The correct, integration-served URL is -> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and -> `houseplan-space-card`) ship in that one file — no separate resource is needed. +### Manual installation -### Manually +Copy `custom_components/houseplan` to `config/custom_components`, restart Home +Assistant, and add the House Plan integration. -1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant. -2. Restart Home Assistant. -3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**. +### Add the card -### Adding a plan screen - -Create a new dashboard tab (a "Panel" view works best) and add the card: +Create a dashboard view (Panel works best) and add the card in the UI or as: ```yaml type: custom:houseplan-card title: House plan ``` -Nothing else needs to be specified — everything else is configured right on the screen. +Different screens may start on different spaces: ---- +```yaml +type: custom:houseplan-card +default_floor: ground +``` -## How to use +All cards share server-side rooms and coordinates. Current mode, viewport and +selected space remain local to the screen. Revision checks and live sync cover +concurrent clients, but avoid editing the same object in two browsers at once. -### Step 1. Add a space (floor) +## Detailed documentation -On first open the plan is still empty — House Plan immediately offers to create the first space. -If your Home Assistant already has **floors** configured, a wizard offers to create a space -for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped). +- [Full user guide](docs/USER-GUIDE.md) +- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input) +- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance) +- [Background editor](docs/DECOR-EDITOR.md) +- [Robot vacuums](docs/VACUUM.md) +- [Touch support](docs/TOUCH-SUPPORT.md) -![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png) + -In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor. +## Support and feedback -![Space creation dialog](docs/images/03-space-dialog.png) +- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan). +- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues). +- Before reporting, update House Plan, restart HA and hard-refresh the page. + Include the version, browser, logs and reproduction steps; private entity IDs + may be replaced with fictional ones. -> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in. +Documentation screenshots are produced by the reproducible +`npm run docs:capture` command using synthetic data only. Scenario version, +source fingerprint and every image hash are recorded in the +[screenshot manifest](docs/images/manifest.json). -Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs. - -### Step 2. Outline the rooms - -After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point. - -As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button. - -![Marking up a room and saving it](docs/images/05-room-dialog.png) - -While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm). - -Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later: - -- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive. -- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area. - - -### Doors, windows, gates and locks - -In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the -opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm, -window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**. - -With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc -draws itself in as the real door opens; a window opens its two casements. While open, the moving -parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width -leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light -passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when -locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click -on the opening shows a status card with both states instead. - -Openings are easy to adjust later: hovering one highlights it, you can **drag it along the -walls** (it slides around corners too), and a **double click opens its properties**. - -### Step 3. Devices appear by themselves - -As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates. - -By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again. - -From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant. - -![Device card on click](docs/images/08-info.png) - -### Step 4. Zoom - -The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space. - -![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png) - -### Step 5. Put the icons in their places - -Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout. - -![Dragging icons — available at all times](docs/images/06-edit.png) - -### Tap actions: control devices from the plan - -By default a tap on an icon opens its info card. A device can instead use the -universal **Toggle state** action. Its editor shows the exact entity or configured -group, the current state and what the next tap will do; when nothing can be toggled, -it says so and the tap is a quiet no-op rather than an unexpected info-card fallback. -Lights keep their convenient toggle default. Covers and valves use open/close/stop -semantics automatically, while locks, alarm panels and secure garage/door/gate covers -remain blocked. An exact entity binding never falls through to a sibling switch, and -temporarily unavailable group members are skipped without erasing the configuration. -A **long press** still opens the info card and right-click still opens HA more-info. - -### Icon rules - -Which MDI icon a device gets is decided by **icon rules** — editable right in the card -(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a -live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule -matches, the entity *device class* decides (thermometer for temperature sensors, etc.). - -### Step 6. Adding your own devices manually - -You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon. - - -Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**. - -To represent a dumb physical lamp controlled by a smart relay, place a virtual -point where the lamp really is and set **Light source → Always**. Manual colour, -brightness and radius stay available even though the point has no HA entity. -Then open the relay and add that plan source under **Controls other light -sources**. The relay continues to show the aggregate working state, while Glow, -room fill and statistics belong to the lamp's position. An unlinked passive -Always source is deliberately constant-on. With several own `light.*`/`switch.*` -entities, Always also offers a leading-entity selector; a missing saved choice -is warned about and retained while a deterministic fallback is used. - -To make that virtual lamp manually switchable without creating a Home -Assistant helper, also choose **Tap action → Toggle state** on the lamp itself. -This exact combination — virtual binding, **Light source → Always**, and -**Toggle state** — stores a shared on/off state in the House Plan integration. -It survives page reloads and Home Assistant restarts and updates Glow, room -fill/statistics, full cards and `houseplan-space-card` together. Any signed-in -dashboard viewer may toggle it. While this manual mode is active, saved -**Controls other light sources** remain intact but are not called; changing the -role, binding or tap action restores their normal behaviour. This operational -state is deliberately not part of plan exports or Home Assistant entities. - -The same dialog controls how the device looks on the plan. **Display** switches between the -icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint -dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The -**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and -turned the way it is mounted. - -![Adding a device manually](docs/images/07-marker-dialog.png) - -### Styling the plan with card-mod (advanced, unsupported) - -The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**. - ---- - -## Uninstalling - -1. Remove the card (or the tab with the plan) from the dashboard. -2. **Settings → Devices & Services → House Plan → Delete** the integration entry. -3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant. -4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory. - ---- - -## Getting help & sharing your plan - -- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions, - setup help, feature ideas, and screenshots of your plans. The fastest way to - reach the author and other users. -- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug - reports and feature requests (please attach your House Plan version). -- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) — - longer-form ideas. -- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version - ([на русском](docs/CHANGELOG.ru.md)). - -When reporting a problem, the version number helps a lot: it is shown in the -browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices & -Services → House Plan**. - ---- - -## Frequently asked questions - -**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse. - -**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant. - -**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon. - -**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant. - ---- - -

Screenshots were taken on a real Home Assistant configuration.

+License: [MIT](LICENSE). diff --git a/README.ru.md b/README.ru.md index a7c6d200..9b232078 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,351 +1,161 @@ -# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant +# 🏠 House Plan — живой план дома для Home Assistant [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) [![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases) -[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers) +[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions) [![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech) [![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan) -📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)** +📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)** -**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или -нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на -своих местах: живые состояния, свет по клику, температура и влажность по -комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный -киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних -редакторов — весь план настраивается прямо на дашборде. + -> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и -> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны -> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные -> операции могут быть менее удобны, работать ограниченно или отсутствовать. +## Дом целиком — одним взглядом -![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif) +House Plan превращает Home Assistant в живую карту дома. Загрузите изображение +плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home +Assistant — и устройства появятся на плане автоматически. Сразу видно, где +горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал +датчик протечки. -> ### 🚀 Попробовать вживую — без установки -> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home -> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё: -> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам -> возвращается в исходное состояние. +![Синтетический дом в режиме просмотра: комнаты, устройства, свет и климат](docs/images/01-view-desktop.png) -🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan) +Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и +внешнего редактора плана. Данные плана и расположение устройств хранятся на +сервере Home Assistant и синхронизируются между экранами. -**Главное** +> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают +> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и +> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны. +> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md). -- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который - нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на - любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте - план обратно в экран. -- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова, - виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются - перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и - линейка в реальных метрах. -- 💡 **Свет переключается кликом** из коробки; значок выключателя может - управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты). -- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа - освещает ровно тот пол, который видит: через проёмы и открытые границы, - а стены, колонны и перегородки его не пропускают и дают настоящие тени. -- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём - (белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают - в комнаты настоящие клинья солнечного света; облачность — опционально, от - weather-сущности. -- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или - останавливает штору, а сам значок морфится между открытым и закрытым - видом и мягко пульсирует кольцом, пока штора едет. -- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»; - температурная заливка по комфортным границам. -- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой, - никогда случайным тапом. -- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между - этажами, автокарусель, свои размеры на каждом экране. -- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая - шайба ездит по плану в реальном времени, «выливая» путь из-под себя; - текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик - (по именам комнат) или перетаскиванием призрака карты. Диагностика и явный - выбор источника поддерживают registry-less камеры карт и не подменяют молча - сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum - (Tasshack) и Valetudo. -- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка - хранится **на сервере HA** — один план для всех экранов, живая синхронизация. + ---- +## Что умеет House Plan +- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства + переключаются с плана; замок нельзя открыть случайным нажатием. +- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы; + «Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии, + подписи и мебель. +- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически, + а карточки комнат показывают температуру, влажность, свет и средний LQI. +- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и + солнечные лучи из окон. +- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки; + робот показывает позицию, базу и пройденный путь. +- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения, + локальный масштаб и отдельный стартовый этаж для каждой карточки. +- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры + значков, сохранённые отдельно для этого устройства. -## Что это и зачем +![Тот же синтетический дом в touch-режиме просмотра](docs/images/02-view-touch.png) -House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота. + -Это удобно, когда: +## Первая рабочая комната -- устройств много, и списками пользоваться неудобно; -- нужно быстро понять состояние дома «одним взглядом»; -- хочется отдать доступ близким — по картинке разберётся любой; -- вы хотите красивый обзорный экран для настенного планшета. +1. Установите интеграцию и добавьте карточку на дашборд. +2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите + вариант без изображения, чтобы нарисовать план вручную. +3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните + контур нажатием на первую точку. +4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без + устройств выберите «Без зоны». +5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически; + перетащите маркеры в нужные места. +6. При необходимости оформите подложку линиями, текстом и мебелью. +7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает + безопасные действия. -Интеграция состоит из двух частей, которые ставятся вместе: +![Создание первого пространства](docs/images/03-space-create.png) -- **карточка Lovelace** `houseplan-card` — сам интерактивный план; -- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах. +![Замыкание контура комнаты на первой точке](docs/images/04-room-contour-close.png) ---- +![Выбранная перегородка и контекстная панель редактора плана](docs/images/05-plan-context-tray.png) -## Чем отличается от аналогов +![Настройка устройства с источником привязки и точным результатом действия](docs/images/06-device-editor.png) -Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` -или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо -YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой -план** на серверной интеграции Home Assistant: +![Живой предпросмотр отображения того же устройства](docs/images/06-device-display-preview.png) -| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) | -|---|---|---|---| -| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке | -| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж | -| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте | -| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) | -| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда | -| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки | -| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст | +Пошаговые сценарии, все инструменты и особые случаи описаны в +[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно +зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в +[руководстве по пылесосам](docs/VACUUM.md). -**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не -универсальная CAD-система. Редактор подложки покрывает практический декор, -надписи и мебель; для свободного архитектурного черчения лучше отдельный -draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout -на всех планшетах. - -Ключевые преимущества коротко: - -- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами. -- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане. -- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию. -- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени. - Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**: - лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка - реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно; - если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением. - Оранжевый = открыто / не заперто. Пульсирующее красное - кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow), - где само пятно — индикатор включения, а подложка значка остаётся стандартной. - Полупрозрачный значок = недоступно. Тёмный = покой. -- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол. -- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе. - ---- - -## Настенный планшет / ТВ (киоск-режим) - -Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или -галочка «Режим настенного устройства» в редакторе карточки): - -```yaml -type: custom:houseplan-card -kiosk: true -cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ) -``` - -Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1), -пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту — -настройка размеров значков и текста для ЭТОГО экрана (хранится на -устройстве). Шапку самого Home Assistant скрывают настройки companion-app -или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode). + ## Установка -В один клик, если у вас уже есть HACS: +### Через HACS -[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration) +[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration) +1. В HACS откройте **⋮ → Пользовательские репозитории**. +2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**. +3. Установите House Plan и перезапустите Home Assistant. +4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**. -### Через HACS (рекомендуется) +Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную, +добавьте именно URL, который публикует интеграция: -1. Откройте **HACS → меню (⋮) → Custom repositories**. -2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**. -3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**. -4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**. +```yaml +resources: + - url: /houseplan_files/houseplan-card.js + type: module +``` -Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно. - -> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы -> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*: -> -> ```yaml -> resources: -> - url: /houseplan_files/houseplan-card.js -> type: module -> ``` -> -> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу -> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент -> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`. -> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс -> не нужен. +Не используйте путь к файлу внутри `custom_components`: Home Assistant не +публикует его как JavaScript-модуль. ### Вручную -1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant. -2. Перезапустите Home Assistant. -3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**. +Скопируйте `custom_components/houseplan` в `config/custom_components`, +перезапустите Home Assistant и добавьте интеграцию House Plan. -### Добавление экрана с планом +### Добавление карточки -Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку: +Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо: ```yaml type: custom:houseplan-card title: План дома ``` -Больше ничего указывать не нужно — всё остальное настраивается прямо на экране. +Для нескольких экранов можно задать разные стартовые пространства: ---- +```yaml +type: custom:houseplan-card +default_floor: ground +``` -## Как пользоваться +Все карточки используют общие серверные комнаты и координаты. Текущий режим, +масштаб и выбранное пространство локальны для экрана. Одновременное +редактирование поддерживает синхронизацию и проверку ревизий, но один объект +лучше не менять параллельно в двух браузерах. -### Шаг 1. Добавьте пространство (этаж) +## Где искать подробности -При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство. +- [Полное руководство пользователя](docs/USER-GUIDE.ru.md) +- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты) +- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице) +- [Редактор подложки](docs/DECOR-EDITOR.md) +- [Роботы-пылесосы](docs/VACUUM.md) +- [Поддержка touch](docs/TOUCH-SUPPORT.md) -Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать -пространство для каждого: названия подставятся сами, план попросит по очереди, -любой этаж можно пропустить. + -![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png) +## Помощь и обратная связь -В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки. +- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan). +- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues). +- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое + обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги + воспроизведения; приватные entity ID можно заменить вымышленными. -![Диалог создания пространства](docs/images/03-space-dialog.png) +Скриншоты в документации получены воспроизводимой командой +`npm run docs:capture` только на синтетических данных. Версия сценариев, +fingerprint исходников и хеш каждого изображения находятся в +[манифесте снимков](docs/images/manifest.json). -> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении. - -Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками. - -### Шаг 2. Обведите комнаты - -После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке. - -Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**. - -![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png) - -Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см). - -Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже: - -- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся. -- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону. - - -### Двери, окна, ворота и замки - -В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём -примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см, -окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**. - -С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания -дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто, -подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек — -зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя** -переключить — клик по проёму показывает карточку с обоими статусами. - -Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль -стен** (в том числе за угол), а **двойной клик открывает свойства**. - -### Шаг 3. Устройства появляются сами - -Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов. - -По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации. - -Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant. - -![Карточка устройства по клику](docs/images/08-info.png) - -### Шаг 4. Масштаб - -Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства. - -![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png) - -### Шаг 5. Расставьте значки по местам - -Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку. - -![Перетаскивание значков — доступно всегда](docs/images/06-edit.png) - -### Управление с плана (tap actions) - -По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно -переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает -свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета). -Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и -клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге -(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое -нажатие** всегда открывает инфо-карточку. - -### Правила иконок - -Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются -прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка» -с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно -правило не подошло — решает *device class* сущности (термометр для датчиков -температуры и т.п.). - -### Шаг 6. Добавление своих устройств вручную - -Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой. - - -Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**. - -В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок, -анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая -точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец -на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене -может быть маленьким и повёрнутым так, как он установлен. - -![Добавление устройства вручную](docs/images/07-marker-dialog.png) - -### Свои стили через card-mod (для продвинутых, без поддержки) - -Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**. - ---- - -## Удаление - -1. Уберите карточку (или вкладку с планом) из дашборда. -2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции. -3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant. -4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`. - ---- - -## Помощь и обмен опытом - -- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы, - помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ - связаться с автором и другими пользователями. -- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги - и запросы фич (пожалуйста, указывайте версию House Plan). -- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для - развёрнутых обсуждений. -- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии. - -Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в -**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно -быстрее. - ---- - -## Часто задаваемые вопросы - -**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой. - -**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant. - -**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок. - -**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant. - ---- - -

Скриншоты сделаны на реальной конфигурации Home Assistant.

+Лицензия: [MIT](LICENSE). diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index eac6a210..52716caf 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -1,4 +1,4 @@ -globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` +globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` ${this.icon?j``:G} ${this.title} `;return this._useHaDialog?j` createHash('sha256').update(value).digest('hex'); + +export const DOC_SCREENSHOT_VERSION = 1; +export const DOC_SCREENSHOTS = Object.freeze([ + { + id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual', + space: 'golden-lighting', mode: 'view', roomMetrics: true, + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 900 }, capture: 'page', + }, + { + id: 'view-touch', file: '02-view-touch.png', fixture: 'visual', + space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true, + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 390, height: 760 }, capture: 'page', + }, + { + id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true, + title: 'House Plan', language: 'en', theme: 'dark', + viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true, + }, + { + id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual', + space: 'golden-geometry', mode: 'plan', + wallJunctionPreview: { + path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]], + pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15, + }, + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 900 }, capture: 'page', + }, + { + id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual', + space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection', + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 900 }, capture: 'page', + }, + { + id: 'device-editor', file: '06-device-editor.png', fixture: 'visual', + space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two', + deviceName: 'Living-room ceiling light', + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true, + }, + { + id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual', + space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two', + deviceName: 'Living-room ceiling light', devicePresentationPreview: true, + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true, + }, + { + id: 'background-editor', file: '07-background-editor.png', fixture: 'visual', + space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection', + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 900 }, capture: 'page', + }, + { + id: 'room-card', file: '08-room-card.png', fixture: 'visual', + space: 'golden-lighting', mode: 'view', roomMetrics: true, + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1180, height: 900 }, capture: 'room-card', + }, + { + id: 'device-info', file: '09-device-info.png', fixture: 'visual', + space: 'golden-lighting', mode: 'view', dialog: 'device-info', + deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light', + title: 'House Plan — synthetic home', language: 'en', theme: 'dark', + viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true, + }, +]); + +const roomCardClip = (page) => page.evaluate(() => { + const card = window.__goldenCard; + const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])]; + const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0]; + if (!target) throw new Error('documentation room card is missing'); + const rect = target.getBoundingClientRect(); + const marginX = 80; + const marginY = 70; + return { + x: Math.max(0, rect.left - marginX), + y: Math.max(0, rect.top - marginY), + width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX), + height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY), + }; +}); + +mkdirSync(OUTPUT, { recursive: true }); +copyFileSync(BUNDLE, DEMO_BUNDLE); +copyFileSync(BUNDLE, INTEGRATION_BUNDLE); + +const { page, browser } = await launch(); +const browserErrors = []; +page.on('pageerror', (error) => browserErrors.push(error.message)); + +try { + const fingerprint = await assertFreshDemoBundle(page, ROOT); + const scenarios = {}; + for (const scenario of DOC_SCREENSHOTS) { + const runtime = await prepareGoldenScenario(page, scenario); + if (scenario.expectDialog && !runtime.dialog) + throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`); + const clip = scenario.capture === 'room-card' + ? await roomCardClip(page) + : await goldenClip(page, scenario.capture); + const image = await page.screenshot({ + ...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css', + }); + writeFileSync(resolve(OUTPUT, scenario.file), image); + scenarios[scenario.id] = { + file: scenario.file, + viewport: scenario.viewport, + theme: scenario.theme, + language: scenario.language, + sourceSha256: fingerprint, + imageSha256: sha256(image), + }; + console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`); + } + if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`); + const manifest = { + version: DOC_SCREENSHOT_VERSION, + fixture: 'synthetic-only', + sourceFingerprint: fingerprint, + captureScriptSha256: sha256(readFileSync(SCRIPT)), + command: 'npm run docs:capture', + scenarios, + }; + writeFileSync(resolve(OUTPUT, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} finally { + await browser.close(); +} diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index f6b1c698..8fa4811a 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -1,7 +1,16 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs'; import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs'; -const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture(); +const fixtureFor = (name) => { + if (name === 'large') return makeLargeHouseFixture(); + if (name === 'empty') { + return { + config: { spaces: [], markers: [], settings: {} }, + layout: {}, devices: {}, entities: {}, states: {}, areas: {}, + }; + } + return makeVisualMatrixFixture(); +}; const themeVars = { dark: { @@ -120,7 +129,8 @@ export function prepareGoldenFixture(scenario) { fixture.devices[scenario.deviceId].name = scenario.deviceName; } if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean' - || typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') { + || typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean' + || scenario.roomMetrics) { const space = requireSpace(); space.settings = { ...(space.settings || {}), @@ -129,6 +139,9 @@ export function prepareGoldenFixture(scenario) { ...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}), ...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}), ...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}), + ...(scenario.roomMetrics ? { + label_temp: true, label_hum: true, label_lqi: true, label_light: true, + } : {}), ...(scenario.customFill ? { custom_fill: scenario.customFill } : {}), }; } @@ -276,7 +289,7 @@ export async function prepareGoldenScenario(page, scenario) { } const host = document.getElementById('host'); const cardConfig = { - type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4, + type: 'custom:houseplan-card', title: scenario.title || `Golden ${scenario.id}`, icon_size: 3.4, language: scenario.language || 'en', ...(scenario.kiosk ? { kiosk: true } : {}), }; @@ -285,7 +298,7 @@ export async function prepareGoldenScenario(page, scenario) { user: { id: 'golden', name: 'Golden fixture', is_admin: true }, devices: fixture.devices || {}, entities: fixture.entities || {}, areas: fixture.areas || {}, states: fixture.states || {}, - floors: { + floors: scenario.noFloors ? {} : { one: { floor_id: 'one', name: 'One', level: 0 }, two: { floor_id: 'two', name: 'Two', level: 1 }, three: { floor_id: 'three', name: 'Three', level: 2 }, @@ -538,6 +551,21 @@ export async function prepareGoldenScenario(page, scenario) { if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1) throw new Error('golden viewport does not show the complete device light-source controls'); } + if (scenario.devicePresentationPreview) { + const dialog = card.renderRoot.querySelector('hp-dialog'); + const body = dialog?.querySelector('.body'); + const preview = dialog?.querySelector('hp-device-preview'); + await preview?.updateComplete; + if (!body || !preview) throw new Error('golden device presentation preview is missing'); + const bodyRect = body.getBoundingClientRect(); + const previewRect = preview.getBoundingClientRect(); + body.scrollTop += previewRect.top - bodyRect.top - 180; + await frame(); + const visibleBody = body.getBoundingClientRect(); + const visiblePreview = preview.getBoundingClientRect(); + if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1) + throw new Error('golden viewport does not show the device presentation preview'); + } if (scenario.openHelp) { const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`); await help?.updateComplete; @@ -557,6 +585,15 @@ export async function prepareGoldenScenario(page, scenario) { await dialog?.updateComplete; dialog?.renderRoot?.querySelector('.close')?.focus(); } + } else if (scenario.dialog === 'device-info') { + const device = card._devices.find((item) => item.id === scenario.deviceId); + if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`); + card._infoCard = device; + card.requestUpdate(); + await card.updateComplete; + await frame(); + if (!card.renderRoot.querySelector('hp-dialog')) + throw new Error(`golden device info did not open: ${scenario.deviceId}`); } else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') { const full = scenario.dialog === 'backup-full'; card._backupImportDialog = { diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js index eac6a210..52716caf 100755 --- a/demo/srv/assets/houseplan-card.js +++ b/demo/srv/assets/houseplan-card.js @@ -1,4 +1,4 @@ -globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` +globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` ${this.icon?j``:G} ${this.title} `;return this._useHaDialog?j`{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` +globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let n=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const o=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new n(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(p.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"",r=z;for(let e=0;e"===l[0]?(r=n??z,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?N:'"'===l[3]?H:L):r===H||r===L?r=N:r===F||r===O?r=z:(r=N,n=void 0);const d=r===N&&t[e+1].startsWith("/>")?" ":"";o+=r===z?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iI(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):void this._requestClose()}if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=[...this._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j` ${this.icon?j``:G} ${this.title} `;return this._useHaDialog?j`95% goal); mypy strict. -5. The demo harness lives in /tmp/demo (synthetic home: demo.html + capture.mjs) — - rebuildable from this repo + docs/DEVELOPMENT.md notes; frames → PIL → GIF. +5. Roadmap: phases 7–10 are DONE (v1.12.0 quality scale, v1.13.0 universality, + v1.13.1 distribution). Next candidates: measure backend coverage (>95% goal); + mypy strict. +6. The public-doc screenshot harness is versioned in `demo/docs/capture.mjs` and + reuses the production component plus deterministic golden fixtures. ## How to resume work in a fresh session (checklist) diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md new file mode 100644 index 00000000..00f4ce5a --- /dev/null +++ b/docs/USER-GUIDE.md @@ -0,0 +1,580 @@ +# House Plan — complete user guide + +Current for **v1.64.0**. This guide describes the interface implemented by the +current source. [Русская версия](USER-GUIDE.ru.md). + +House Plan installs two Lovelace cards together: + +- `custom:houseplan-card` — the live plan, editors, state and actions; +- `custom:houseplan-space-card` — an inert rendering of one space with a link + to the full plan. + +Configuration, uploaded files and shared positions stay inside Home Assistant. +House Plan does not use a House Plan cloud service. + +> **Input support.** View and kiosk are supported on phones, tablets and wall +> touch panels. Create and maintain a plan on a desktop browser with a mouse +> and keyboard. Touch editing is best effort: an operation may be awkward, +> limited or absent, but it must not corrupt data or trigger an accidental +> Home Assistant action. The authority is [TOUCH-SUPPORT.md](TOUCH-SUPPORT.md). + +## Contents + +1. [Data model and terms](#1-data-model-and-terms) +2. [Installation and access](#2-installation-and-access) +3. [Adding a card](#3-adding-a-card) +4. [Quick start](#4-quick-start) +5. [Interface modes](#5-interface-modes) +6. [Navigation, zoom and input](#6-navigation-zoom-and-input) +7. [Spaces](#7-spaces) +8. [Rooms and walls](#8-rooms-and-walls) +9. [Doors, windows, gates and locks](#9-doors-windows-gates-and-locks) +10. [Devices](#10-devices) +11. [Tap actions](#11-tap-actions) +12. [Device visual states](#12-device-visual-states) +13. [Room fills and light](#13-room-fills-and-light) +14. [Background editor](#14-background-editor) +15. [Sun background and window rays](#15-sun-background-and-window-rays) +16. [Robot vacuums](#16-robot-vacuums) +17. [Kiosk](#17-kiosk) +18. [Static space card](#18-static-space-card) +19. [Plan maintenance](#19-plan-maintenance) +20. [Storage, multiple cards and backups](#20-storage-multiple-cards-and-backups) +21. [Current limitations](#21-current-limitations) +22. [Troubleshooting](#22-troubleshooting) + + + +## 1. Data model and terms + +Settings form a hierarchy. Global settings provide defaults; a space may +override them; a room may override its space; a marker may override its room. + +| Level | Meaning | Stored data | +|---|---|---| +| Card | One dashboard instance | Initial space, language, icon size, value/LQI display, live state, kiosk and cycle | +| Global settings | Defaults for all spaces | Fill palette, background, Glow radius, north, sun, weather and icon rules | +| Space | Floor, yard, garage or building | Plan image, scale, rooms, walls, openings, decor and display settings | +| Room | A closed outline | Name, optional HA area, temperature/humidity source and local fill | +| Wall | A side of a room outline | Optional physical thickness and virtual spans on a shared boundary | +| Opening | Door, window or gate on a wall | Size, orientation, contact and optional lock | +| Marker | A device shown on the plan | HA binding, room, action, presentation, light role and attachments | +| Background item | Visual context | Line, shape, text, furniture or plan-image transform | + +A room is the spatial unit that owns area and an optional HA area. A partition +or column affects the physical rendering and light but does not create a room. + + + +## 2. Installation and access + +### Requirements + +| Component | Requirement | +|---|---| +| Home Assistant | 2024.6.0 or newer | +| Installation | Any installation that supports custom integrations | +| Browser | Web Components, SVG and Pointer Events | +| Editing permission | Administrators by default | + +### HACS + +1. Add `https://github.com/Matysh/houseplan-card` to HACS as a custom + **Integration** repository. +2. Install House Plan and restart Home Assistant. +3. Open **Settings → Devices & services → Add integration → House Plan**. +4. Keep “administrators only” enabled unless other users must edit the plan. + +The integration registers its Lovelace resource. With YAML-managed resources, +add: + +```yaml +resources: + - url: /houseplan_files/houseplan-card.js + type: module +``` + +Do not use `/custom_components/houseplan/frontend/houseplan-card.js`; it is an +on-disk path, not the JavaScript URL served by Home Assistant. + +### Manual installation + +Copy the release folder to `config/custom_components/houseplan`, restart Home +Assistant, add the integration, then add the resource above only if Lovelace +resources are YAML-managed. + +### Permissions + +Every signed-in user can view the plan. Home Assistant permissions still govern +device service calls. With the default integration option, only administrators +can edit configuration or upload files. Plan optimization and its undo always +require an administrator. + +## 3. Adding a card + +Minimal configuration: + +```yaml +type: custom:houseplan-card +title: House plan +``` + +| Field | Default | Purpose | +|---|---:|---| +| `title` | empty | Card title | +| `default_floor` | first/last opened | Initial space for this card | +| `language` | HA language | `auto`, `ru` or `en` | +| `icon_size` | `2.5` | Base marker size, 1–6% of plan width | +| `show_temperature` | `true` | Compact temperature and humidity values | +| `live_states` | `true` | Work/open/unavailable presentation and activity; alarms remain visible | +| `show_signal` | `true` | Base LQI display, overridable by a space | +| `kiosk` | `false` | Full plan without editors or header | +| `cycle` | `0` | Kiosk auto-cycle interval in seconds; `0` disables it | + +The legacy card-level `tap_action` is ignored. Each marker owns its action. + + + +## 4. Quick start + +Use this order for a first working room: + +1. Add a space with **+**, or import Home Assistant floors. +2. Upload SVG/PNG/JPG/WebP, reuse an uploaded plan, or choose no image. +3. Set the real size of a grid cell. It controls wall, opening, furniture and + area measurements. +4. In Plan choose **Room outline**, click the vertices, then click the first + point to close the outline. +5. Name the room and bind it to an HA area, or use **No area**. +6. Add wall thickness and openings if needed. +7. In Device, position auto-discovered markers and choose their actions. +8. In Background, align the plan image and add labels, lines or furniture. +9. Return to View and confirm live values and actions. + +![Creating the first space with synthetic data](images/03-space-create.png) + +![Closing a new room outline on its first point](images/04-room-contour-close.png) + +Do all plan creation on desktop. Phone, tablet and wall-panel View remain full +product surfaces after setup. + + + +## 5. Interface modes + +View is the state with no editor open. Close the active editor to return to it. + +| Mode | Devices | Geometry | Background/openings | +|---|---|---|---| +| View | Live and actionable | Read-only; room hover shows summary | Visible according to space settings | +| Plan | Hidden | Rooms, walls, columns and openings editable | Openings always visible | +| Device | Draggable; click opens settings | Read-only background | Read-only background | +| Background | Dimmed and inert | Dimmed | Background objects editable | +| Kiosk | Actionable as in View | Read-only | Read-only; no editors | +| Static card | Not live or interactive | Render only | Render only | + +Each editor has a stable primary toolbar. Tool parameters and selected-object +actions appear in a context tray over the top of the canvas. On a narrow screen +the tray scrolls horizontally instead of shrinking the plan. + + + +## 6. Navigation, zoom and input + +The canvas grows with actual content. **Fit all** frames rooms and any distant +objects; each space keeps its local View viewport. + +| Scenario | Mouse | Touch View | Touch editors | Keyboard | +|---|---|---|---|---| +| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — | +| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — | +| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface | +| Draw or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` cancels the operation | +| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | +| Kiosk sizing | — | Hold empty space for 3 seconds | — | — | + +At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space. +Any manual kiosk operation pauses auto-cycle for 60 seconds. + +### Cancel and undo + +- `Esc` cancels an unfinished path, current drag/resize/rotation, or the top + dialog without undoing an already committed action. +- `Ctrl/Cmd+Z` undoes an editor command; redo is `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`. +- Undo/Redo stores up to 50 named commands for Plan and Background. + + + +## 7. Spaces + +A space may represent a floor, garden, garage or separate structure. It stores +its image, grid scale, geometry, Background layer and display overrides. + +### Plan source + +- upload SVG, PNG, JPG or WebP; +- select a previously uploaded plan; +- choose no image and draw the geometry by hand. + +The plan image keeps its proportions initially. Background can later move, +scale or rotate it. Detaching a plan never deletes its server file; deletion +requires an explicit user action. + +### Display settings + +A space can show/hide room borders, names, LQI, Background and openings. It can +override fill mode, Glow, day-cycle background, north, sun rays, room-card font +scale and which room metrics are visible. + +Room cards are positioned and scaled on the plan. View renders only the metrics +enabled for that space. + +![Room card with temperature, LQI and light state](images/08-room-card.png) + + + +## 8. Rooms and walls + +### Create a room + +Select **Room outline** and click grid nodes. The current segment shows its real +length. Clicking the first point closes the outline and opens the room dialog. +Rooms may share boundaries but may not overlap. A closed room has area; an open +wall path does not. + +Existing segment endpoints and lines appear above walls while drawing. An +endpoint grows when the next click will join it. A point on a line shows where +the click will create a valid junction. + +### Plan tools at a glance + +| Tool | Result | Room area | Light and shadow | Main limit | +|---|---|---|---|---| +| Room outline | Closed room or persisted open wall segments | Only a closed saved outline has area | Physical segments block light; openings pass it | Rooms cannot overlap; close on the first point | +| Partition | Independent wall segment | Does not split or change area | Blocks light and casts a wall shadow | Does not create a room or HA-area binding | +| Column | Square or circular support | Does not change area | Blocks light inside its shape | One shape/size/rotation; not a wall or room | +| Boundary | Virtual span of a shared wall | Geometry and area stay unchanged | Light passes; no physical wall is painted | Only a shared boundary between adjacent rooms | +| Opening | Door, window or gate | Does not change area | Door/gate passage follows state; window may cast sun | Must fit completely on a suitable wall segment | + +Other operations edit existing geometry: + +| Operation | Result | +|---|---| +| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area | +| Split | Cuts a room from one wall to another; the larger part keeps the original room | +| Resize | Moves a wall with shared geometry or scales a room by corner handles | +| Thickness | Changes one physical span or every wall of a room | +| Delete room | Deletes only the selected room after confirmation | + +![Selected partition and its Plan context tray](images/05-plan-context-tray.png) + +Wall thickness is stored in real units. A room may have different thicknesses +on different spans. Open wall branches and T-junctions are allowed; shared +geometry remains joined without painted end caps. + +### HA area binding + +One HA area may be bound to one room. The binding drives automatic device +placement, room LQI, light and aggregate climate. A room without an area may +still contain manually assigned devices and explicit measurement sources. + +## 9. Doors, windows, gates and locks + +Choose Opening, select door/window/gate, and click a wall. Defaults are 90 cm, +120 cm and 300 cm. The complete opening must fit on the wall. + +An opening may bind a contact; doors and gates may also bind a lock. View paints +the moving leaf and state. A lock badge is green when locked and orange when +unlocked. A plan tap never toggles a lock. The opening card provides a labelled +lock/unlock control, with confirmation before unlocking. + +Openings may slide along joined wall corners. A double click in Plan opens +properties. Thick walls keep visible jambs and align the symbol to the correct +face. + + + +## 10. Devices + +House Plan reads Home Assistant device, entity and area registries. A device in +a bound HA area receives an automatic marker. Service-only records, bridges and +other non-spatial records are filtered; a light group may replace its members. +Newly discovered devices get a red dot until first opened in Device. + +### Bindings + +| Binding | Use | +|---|---| +| HA device | Uses its related entities and resolves a primary function | +| HA entity | Exact entity binding after enabling “Show entities” | +| Virtual device | Label/icon/description only, with no live active state | + +The same binding cannot be used by two markers. + +### Device editor + +- drag a marker to save its server-side position; +- click it to edit name, binding, room, tap action and presentation; +- **Add** creates a virtual marker or picks a binding manually; +- **Hidden and disabled** reveals user-hidden and HA-disabled records only in + the editor; +- **Icon rules** edits the first-match regular-expression list. + +The dialog shows binding provenance, exact next tap result, skipped targets and +a live presentation preview. A saved missing source is shown as missing rather +than silently replaced. + +![Device editor with binding provenance and the exact action result](images/06-device-editor.png) + +![Live preview of the selected device presentation](images/06-device-display-preview.png) + +Hidden markers keep configuration and may still contribute to area aggregates. +An HA-disabled binding is excluded from rendering, state, actions, light and +aggregates until the same ID becomes active again. + +## 11. Tap actions + +| Gesture | View | Device editor | +|---|---|---| +| Short click/tap | Configured action | Open marker settings | +| Hold 600 ms | House Plan device card | No device control | +| Right click | Native HA more-info for the primary entity | Browser/editor context | + +| Action | Behaviour | Safety | +|---|---|---| +| Device card | House Plan card with entities, model, description, links and files | No state change | +| HA more-info | Native dialog for the exact primary entity | No state change | +| Toggle state | Toggles the exact binding, supported device function or configured light-source group | Locks, alarm panels and protective garage/door/gate targets are no-op; confirmation is optional | +| Run | Runs an automation, script or scene | Explicit target; confirmation is optional | + +A light defaults to Toggle; other devices default to the House Plan card. An +unsupported Toggle remains a visible no-op and is never changed into another +action behind the user's back. + +![House Plan device card with state and safe actions](images/09-device-info.png) + + + +## 12. Device visual states + +Presentation has three independent layers: stable marker background, icon or +value, and optional activity pulse. Priority is **alarm → working → open → +neutral**. + +| State | Meaning | Examples | +|---|---|---| +| Red alarm | Critical condition, even with live states disabled | Smoke, gas, CO, leak, tamper/problem/safety, triggered alarm | +| Yellow | Device is doing its main job | Light/switch/fan on, active climate, vacuum cleaning, known appliance work | +| Orange | Physically open or unlocked | Door/window contact, unlocked lock, opening valve | +| Faded | Data unavailable | All relevant entities unknown, unavailable or absent | +| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked | + +Activity may be a finite three-wave event, persistent presence, a travelling +transition, or persistent work. `prefers-reduced-motion` replaces ordinary +motion with a compact indicator while the alarm remains clear. + +The four display choices are icon + state; icon + state + activity; value + +state; and always-static icon. A separate value badge can show an entity state, +useful attribute, average LQI or linked light state on any side of the marker. + +Virtual devices use the ordinary neutral/hover background with a dashed outer +circle. They cannot have active state or pulse. Unavailable keeps the ordinary +presentation with the standard icon opacity reduction. Zigbee LQI remains a +separate badge. + +## 13. Room fills and light + +Space fill modes include user colour, temperature comfort range and LQI. Room +settings may override the space. Glow is independent from the base fill. + +A light source may come from automatic classification, an explicit Always +role, or a controlled source group. Walls, partitions and columns occlude Glow; +open passages transmit it. When a configured light source disappears or loses +its valid binding, its contribution is removed instead of keeping stale light. + +Overlapping Glow pools add brightness and colour where browser SVG blending is +supported; otherwise House Plan uses a safe normal blend without changing the +saved setting. + + + +## 14. Background editor + +The authoritative technical interaction contract is +[DECOR-EDITOR.md](DECOR-EDITOR.md). + +| Tool | Create | Edit | +|---|---|---| +| Select | Select an item | Move, scale, rotate; double click properties; Delete/Backspace removes | +| Backdrop | Available when a plan image exists | Move, corner-scale, rotate; double click numeric size/angle | +| Line | Drag endpoints | Colour/opacity, physical thickness and solid/dashed style | +| Rectangle | Drag diagonal; Shift makes a square | Stroke plus independent fill, size and angle | +| Oval | Drag bounds; Shift makes a circle | Stroke plus independent fill, radii and angle | +| Text | Click to open dialog | Multiline text, HA tokens, colour, physical size and angle | +| Furniture | Pick symbol, then click | Symbol, size, colour, outline and wall magnet | +| Erase | Click an item | Confirmed deletion, undoable | + +Creation and transform snap to the grid plus nearby room/background anchors. +The plan image is interactive only with Backdrop selected. Undo/Redo shares the +50-command editor history. + +![Selected line in the Background editor](images/07-background-editor.png) + +Live text accepts `{sensor.entity}` and +`{climate.entity:current_temperature}` tokens. Missing, unavailable or complex +object values render as `—`; long values are shortened. + +## 15. Sun background and window rays + +These are independent features. **Follow the sun** changes the background from +day through golden hour to night and can fall back to browser time. Window rays +require `sun.sun`, a configured north direction and suitable exterior windows. +Weather cloud cover may reduce ray intensity. + +Rays remain visual only: they do not change Home Assistant state. Walls and +physical obstacles clip them; changing north or window geometry recalculates +the result. + +## 16. Robot vacuums + +The dock marker stays at its saved location while a live puck and path follow a +supported map source. Calibration maps source coordinates to plan coordinates. +Automatic room-name matching is a starting point; manual drag/stretch corrects +it. A diagnostic source picker reports missing or incompatible sources instead +of silently rebinding. + +Multiple maps are represented as distinct sources/calibrations. A vacuum is +shown only in the space whose saved mapping currently matches the active map; +the dock remains in its configured space. See [VACUUM.md](VACUUM.md) for the +source dialect and calibration contract. + +## 17. Kiosk + +Set `kiosk: true` on a card in a Panel view. Editors and the ordinary header are +removed. Pinch/drag navigate, double-tap resets, and a 1:1 horizontal swipe +changes space. Holding empty space for three seconds opens per-display icon and +text sizing. `cycle` enables automatic space changes; interaction pauses it for +60 seconds. + +## 18. Static space card + +`custom:houseplan-space-card` renders one configured space without live states, +hover, drag, more-info or actions. A footer button opens the full plan. Use it +for compact dashboard navigation, not for home control. + +```yaml +type: custom:houseplan-space-card +space: ground +``` + +## 19. Plan maintenance + +Optimization compacts old off-grid geometry and unused layout records while +preserving rooms, bindings and supported settings. It does not delete plan +images or attachments merely because nothing currently references them. + +Optimization creates one server-side undo point. Any later edit makes that undo +stale, so create a Home Assistant backup before a large maintenance operation. + + + +## 20. Storage, multiple cards and backups + +### Portable JSON backup + +**Global settings → Backup and transfer** exports either the complete House +Plan model or the current space. Import first shows a server-side preview with +type, versions, object counts, source and content-link state; nothing is written +until confirmation. + +A full import replaces the model and creates one undo point. A space import +assigns new internal IDs and adds the copy without replacing global settings. +Internal uploaded files are not embedded in JSON; an import to another HA +instance must explicitly detach those links. + +### Storage locations + +| Data | Location | +|---|---| +| Spaces, geometry, Background and marker settings | HA storage `houseplan.config` | +| Marker and room-card positions | HA storage `houseplan.layout` | +| Plan files | `config/houseplan/plans` | +| Marker attachments | `config/houseplan/files` | +| Vacuum path | Separate House Plan HA storage | +| Cache, viewport and kiosk scale | That browser's `localStorage` | + +### Several cards and clients + +Several `custom:houseplan-card` instances may use different `default_floor` +values. This is the supported way to start one wall display on the ground floor +and another on a garage or upper floor. + +- configuration, rooms, Background and device layout are shared server data; +- `default_floor` is an initial choice for that card; +- current mode, selected space, zoom/pan and kiosk sizing are local; +- WebSocket broadcasts saved changes and revision checks reject a stale write; +- avoid editing the same object in two browsers: the second save may need a + refresh and manual reapplication. + +### Files and quotas + +Plan files accept SVG/PNG/JPG/WebP up to 8 MB, with a 200-file/256-MB total. +Marker attachments accept PDF/PNG/JPG/WebP/TXT up to 50 MB, with a +1000-file/1-GB total and 50 links per marker. Writes are also refused below +512 MB free disk space. Detached files remain until explicitly deleted. + + + +## 21. Current limitations + +| Limitation | Practical effect | +|---|---| +| Rooms must be closed | An open path is walls, not a room with area | +| One HA area per room | To split one HA area visually, assign devices manually | +| Boundary requires adjacent rooms | It cannot open an exterior wall or branch from empty space | +| Editors are desktop-first | Touch editing may be awkward, limited or absent | +| Room details use hover in View | A touch-only user may need an editor or another visible metric | +| Sun has no exterior 3D model | It cannot know shadows from trees, awnings or neighbouring structures | +| Icon rules use regular expressions | First matching rule wins and invalid expressions are rejected | + +Storage guards allow up to 50 spaces, 400 rooms per space, 2,000 markers, 500 +openings, 1,000 Background items, 500 wall records and 500 virtual spans per +space. The configuration package is limited to 2 MB. + + + +## 22. Troubleshooting + +### Card does not load + +| Symptom | Check | +|---|---| +| `Custom element doesn't exist: houseplan-card` | Integration loaded; resource is `/houseplan_files/houseplan-card.js` with type `module`; hard-refresh | +| MIME `text/plain` | Replace `/custom_components/...` with `/houseplan_files/...` | +| Integration missing | Folder is exactly `custom_components/houseplan`; restart HA; inspect import errors | + +### Devices or values are missing + +- confirm the room's HA-area binding and the device/entity registry area; +- open Device → Hidden and disabled; +- verify the selected source still exists and is available; +- remember that a virtual marker has no active state; +- inspect the dialog's exact target and skipped-target explanation. + +### Geometry looks wrong + +- confirm grid cell size before judging physical dimensions; +- use Fit all to include distant objects; +- check wall thickness and whether a shared span is a virtual Boundary; +- use desktop for precise nodes, Split and Resize. + +### Vacuum does not move + +- confirm the active map source and map identity; +- check calibration anchors and current source availability; +- verify that the active map is assigned to this space; +- open source diagnostics instead of deleting/recreating the marker. + +When reporting a problem, include House Plan version, HA version, browser, +console/server errors and reproducible steps. Replace private entity IDs and +plans with synthetic equivalents. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 5baaf194..fef6f08a 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1,6 +1,7 @@ # House Plan — полное руководство пользователя -Актуально для **v1.60.0**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев. +Актуально для **v1.64.0**. Руководство составлено по исходному коду и +фактическому интерфейсу этой версии. [English version](USER-GUIDE.md). House Plan — локальная интеграция и две Lovelace-карточки для Home Assistant: @@ -27,7 +28,7 @@ House Plan — локальная интеграция и две Lovelace-кар 6. [Навигация, масштаб и жесты](#6-навигация-масштаб-и-жесты) 7. [Пространства](#7-пространства) 8. [Комнаты и стены](#8-комнаты-и-стены) -9. [Двери, окна и замки](#9-двери-окна-и-замки) +9. [Двери, окна, ворота и замки](#9-двери-окна-ворота-и-замки) 10. [Устройства](#10-устройства) 11. [Действия по нажатию](#11-действия-по-нажатию) 12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств) @@ -42,6 +43,8 @@ House Plan — локальная интеграция и две Lovelace-кар 21. [Ограничения текущей версии](#21-ограничения-текущей-версии) 22. [Диагностика](#22-диагностика) + + ## 1. Модель данных и терминология Настройки образуют иерархию: общие значения задают базовый вид, пространство может их переопределить, затем отдельная комната, затем устройство. @@ -59,6 +62,8 @@ House Plan — локальная интеграция и две Lovelace-кар Важное ограничение версии: стена пока не существует отдельно от комнаты. Она выводится из границы замкнутого контура. Незамкнутые и свободно стоящие стены пока не поддерживаются. + + ## 2. Установка и доступ ### Требования @@ -128,6 +133,8 @@ title: План дома Карточечный `tap_action` из старых конфигураций игнорируется. Действие задаётся отдельно для каждого устройства. + + ## 4. Быстрый старт Рекомендуемый порядок настройки: @@ -148,6 +155,12 @@ title: План дома последующего просмотра и управления домом, но не гарантированной средой редактирования. +![Создание первого пространства на синтетических данных](images/03-space-create.png) + +![Замыкание нового контура комнаты на первой точке](images/04-room-contour-close.png) + + + ## 5. Режимы интерфейса Обычный просмотр — не отдельная вкладка: это состояние, в которое возвращает крестик активного редактора. @@ -173,6 +186,8 @@ title: План дома область и не меняет масштаб плана. На узком экране действия прокручиваются горизонтально внутри самой суб-панели. + + ## 6. Навигация, масштаб и жесты Холст практически бесконечен: элементы можно рисовать и размещать за пределами исходного квадрата плана. Команда **Показать всё** подбирает вид по фактическому содержимому. @@ -182,15 +197,14 @@ Touch-жесты просмотра и киоска из таблицы ниже desktop: для точного рисования, Resize, модификаторов клавиатуры и свойств по двойному клику используйте компьютер. -| Действие | Мышь/клавиатура | Сенсорный экран | -|---|---|---| -| Масштаб | Колесо, кнопки `−`/`+` | Щипок двумя пальцами | -| Панорама | Перетаскивание пустого места | Перетаскивание | -| Показать всё | Центральная кнопка масштаба | Та же кнопка вне киоска | -| Привязка объектов | Выполняется автоматически и всегда | Выполняется автоматически и всегда | -| Сброс киоск-масштаба | — | Двойной тап | -| Следующее пространство в киоске | — | Горизонтальный свайп при масштабе 1:1 | -| Локальный размер киоска | — | Удерживать пустое место 3 секунды | +| Сценарий | Мышь | Touch в просмотре | Touch в редакторах | Клавиатура | +|---|---|---|---|---| +| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — | +| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — | +| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность | +| Рисование и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` отменяет операцию | +| История редактора | Кнопки Undo/Redo | Не применяется | Кнопки могут работать, жест не гарантирован | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | +| Киоск-размеры | — | Удерживать пустое место 3 секунды | — | — | Особенности: @@ -218,6 +232,8 @@ desktop: для точного рисования, Resize, модификато кнопку или объект, которыми диалог был открыт. При открытии вложенного окна возврат сначала происходит в родительский диалог. + + ## 7. Пространства Пространство подходит не только для этажа: это может быть двор, гараж, баня или отдельное строение. @@ -243,6 +259,11 @@ desktop: для точного рисования, Resize, модификато | Отображение | Скрыть проёмы | Скрывает только символы дверей, окон и ворот; свет, солнечные лучи и датчики проёмов продолжают работать | | Карточка комнаты | Температура/влажность/LQI/свет | Добавляет выбранные показатели под названием комнаты | | Карточка комнаты | Общий размер шрифта | Масштабирует все комнатные карточки пространства, 50–300% | + +Карточка комнаты остаётся частью плана: её можно перемещать и масштабировать в +редакторе, а в просмотре она собирает только включённые метрики. + +![Карточка комнаты с температурой, LQI и состоянием света](images/08-room-card.png) | Стиль | Цвет и прозрачность | Общий цвет границ и названий | | Фон | Наследовать/статический/день-ночь | Управляет фоном вокруг плана | | Солнце | Север и лучи | Локально переопределяет общие настройки | @@ -251,6 +272,8 @@ desktop: для точного рисования, Resize, модификато Удаление пространства удаляет его комнаты и разметку после подтверждения. Файл подложки при этом автоматически не удаляется: им можно управлять через список уже загруженных планов. + + ## 8. Комнаты и стены ### Создание комнаты @@ -299,18 +322,27 @@ T-соединение входит в проходящую стену без в ### Инструменты плана -| Инструмент | Что выбирать | Результат и ограничения | -|---|---|---| -| Контур комнаты | Точки контура | Создаёт комнату; незаконченный контур сохраняется и допускает разную толщину сегментов | -| Перегородка | Две точки | Создаёт одну независимую стену с текущей толщиной; комнату и HA-зону не делит | -| Колонна | Одну точку | Создаёт квадратную колонну с текущим размером; форму, размер и поворот можно изменить в свойствах | -| Объединить | Две соседние комнаты | Объединяет геометрию; в диалоге выбирается, чья идентичность/название/HA-зона сохранится | -| Split | Комнату, старт на стене, путь внутри, финиш на стене | Делит комнату; большая часть сохраняет исходную комнату, меньшая получает новое имя/HA-зону | -| Resize | Ручку стены или комнату | Двигает стену с общей геометрией; по клику на комнату даёт угловую рамку масштабирования | -| Проём | Сначала тип в подменю, затем точку на стене | Создаёт окно 120 см, дверь 90 см или ворота 300 см; клик существующего проёма открывает его настройки | -| Граница | Две точки общей стены или существующий пунктир | Два клика делают участок общей стены виртуальным; один клик по пунктиру целиком восстанавливает физическую стену | -| Толщина | Реальный участок стены | Задаёт толщину выбранному участку или всем стенам комнаты | -| Удалить комнату | Точку внутри комнаты | После подтверждения удаляет только эту комнату; стены не запускают другие скрытые действия | +#### Инструменты плана в короткой таблице + +| Инструмент | Что создаёт | Площадь комнаты | Свет и тени | Главное ограничение | +|---|---|---|---|---| +| Контур комнаты | Замкнутую комнату или сохраняемые отрезки незавершённой стены | Только замкнутый сохранённый контур имеет площадь | Физические сегменты блокируют свет; проёмы пропускают его | Комнаты не могут перекрываться; контур замыкается на первой точке | +| Перегородка | Независимый отрезок стены | Не делит и не меняет площадь | Блокирует свет и отбрасывает тень как стена | Не создаёт комнату и не связывается с HA-зоной | +| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой | +| Граница | Виртуальный участок общей стены | Не меняет геометрию и площадь | Свет проходит, физическая стенка не рисуется | Работает только на общей границе соседних комнат | +| Проём | Дверь, окно или ворота на стене | Не меняет площадь | Дверь/ворота пропускают свет по состоянию; окно может давать солнечный луч | Должен целиком помещаться на подходящем отрезке стены | + +Остальные операции меняют уже созданную геометрию: + +| Операция | Результат | +|---|---| +| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона | +| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату | +| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой | +| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты | +| Удалить комнату | После подтверждения удаляет только выбранную комнату | + +![Выбранная перегородка и её контекстная панель](images/05-plan-context-tray.png) ### Merge @@ -449,6 +481,8 @@ Glow и солнечные лучи, но при Resize комнаты оста Солнце проходит только через наружные окна. Внутренние окна солнечных лучей не создают. + + ## 10. Устройства ### Как маркеры появляются автоматически @@ -479,6 +513,10 @@ House Plan читает реестры устройств, сущностей и | **Скрытые и деактивированные** | Показывает пользовательски скрытые и отключённые в HA устройства служебными маркерами только в этой вкладке | | **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» | +![Редактор устройства: источник привязки и ожидаемое действие](images/06-device-editor.png) + +![Живой предпросмотр выбранного отображения устройства](images/06-device-display-preview.png) + ### Основные настройки маркера | Поле | Назначение | @@ -610,6 +648,10 @@ HA. «Показать» заблокировано до активации. П получает ровно показанное доступное подмножество; собственная сущность контроллера не подставляется вместо исчезнувших настроенных целей. +![Внутренняя карточка устройства с состоянием и безопасными действиями](images/09-device-info.png) + + + ## 12. Визуальные состояния устройств В динамических режимах система состоит из трёх независимых слоёв: @@ -816,6 +858,8 @@ marker на плане при этом не меняются. Цвет пятна выбирается по приоритету: RGB-состояние лампы → цветовая температура → общий цвет света. Яркость влияет на интенсивность, но имеет минимальный визуальный порог 15%. + + ## 14. Редактор подложки ### Инструменты @@ -842,6 +886,8 @@ marker на плане при этом не меняются. показывает «Свойства» и «Удалить». Палитра мебели использует расширенный вариант той же поверхности и также не меняет высоту или масштаб рабочей области. +![Выбранная линия в редакторе подложки](images/07-background-editor.png) + Редактор подложки использует тот же именованный Undo/Redo на 50 команд, что и геометрия плана. `Esc` отменяет только незавершённое рисование или текущий drag/resize/поворот; после отпускания указателя используйте `Ctrl+Z`/`Cmd+Z`. Повтор — `Ctrl+Shift+Z` либо `Ctrl+Y`. ### Двойной клик в «Выбрать» @@ -1087,6 +1133,8 @@ show_signal: true Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Новый edit делает резервную копию оптимизации устаревшей. + + ## 20. Хранение, совместная работа и резервные копии ### Переносимая JSON-копия House Plan @@ -1133,6 +1181,22 @@ JSON хранит названия, идентификаторы HA и точн - Масштаб просмотра, последнее открытое пространство и киоск-размеры локальны браузеру. - Если открыт несохранённый диалог и Lovelace быстро пересобирает карточку, текущая версия пытается восстановить черновик в течение короткого окна. +### Несколько карточек и стартовые пространства + +На одном или разных дашбордах можно разместить несколько +`custom:houseplan-card` и задать каждой собственный `default_floor`. Это +поддерживаемый способ открыть, например, первый этаж на настенном планшете, а +гараж — на отдельном экране. + +- конфигурация, комнаты, декор и layout устройств общие и серверные; +- `default_floor` применяется как стартовый выбор конкретной карточки; +- текущий режим, выбранное пространство, масштаб/панорама и киоск-размеры + локальны браузеру или экземпляру карточки; +- WebSocket доставляет сохранённые изменения другим клиентам, а серверные + ревизии не позволяют молча перезаписать более новую модель; +- два пользователя не должны одновременно редактировать один и тот же объект: + второй save может потребовать обновить состояние и повторить изменение. + ### Файлы и квоты | Объект | Форматы | Лимит одного файла | Общий лимит | @@ -1142,6 +1206,8 @@ JSON хранит названия, идентификаторы HA и точн Запись также отклоняется, если на разделе HA остаётся меньше 512 МБ свободного места. Файлы не удаляются только из-за возраста. Подложка, отсоединённая от пространства, остаётся на сервере до явного удаления. Незавершённые временные загрузки очищаются автоматически. + + ## 21. Ограничения текущей версии | Ограничение | Практическое следствие | @@ -1158,6 +1224,8 @@ JSON хранит названия, идентификаторы HA и точн Технические пределы защиты хранилища: до 50 пространств, 400 комнат на пространство, 2000 маркеров, 500 проёмов, 1000 элементов декора, 500 записей стен и 500 виртуальных участков на пространство. Конфигурационный пакет ограничен 2 МБ. + + ## 22. Диагностика ### Карточка не загрузилась diff --git a/docs/external-link-allowlist.json b/docs/external-link-allowlist.json new file mode 100644 index 00000000..24a5374b --- /dev/null +++ b/docs/external-link-allowlist.json @@ -0,0 +1,9 @@ +{ + "transientHosts": [ + "demo.houseplan.tech", + "github.com", + "hacs.xyz", + "my.home-assistant.io", + "t.me" + ] +} diff --git a/docs/images/01-view-desktop.png b/docs/images/01-view-desktop.png new file mode 100644 index 00000000..d0ca9b17 Binary files /dev/null and b/docs/images/01-view-desktop.png differ diff --git a/docs/images/02-onboarding-empty.png b/docs/images/02-onboarding-empty.png deleted file mode 100644 index 622e5b38..00000000 Binary files a/docs/images/02-onboarding-empty.png and /dev/null differ diff --git a/docs/images/02-view-touch.png b/docs/images/02-view-touch.png new file mode 100644 index 00000000..9e8eb69a Binary files /dev/null and b/docs/images/02-view-touch.png differ diff --git a/docs/images/03-space-create.png b/docs/images/03-space-create.png new file mode 100644 index 00000000..4454b778 Binary files /dev/null and b/docs/images/03-space-create.png differ diff --git a/docs/images/03-space-dialog.png b/docs/images/03-space-dialog.png deleted file mode 100644 index c630f90f..00000000 Binary files a/docs/images/03-space-dialog.png and /dev/null differ diff --git a/docs/images/04-markup.png b/docs/images/04-markup.png deleted file mode 100644 index 468dd74f..00000000 Binary files a/docs/images/04-markup.png and /dev/null differ diff --git a/docs/images/04-room-contour-close.png b/docs/images/04-room-contour-close.png new file mode 100644 index 00000000..4a5c2016 Binary files /dev/null and b/docs/images/04-room-contour-close.png differ diff --git a/docs/images/05-plan-context-tray.png b/docs/images/05-plan-context-tray.png new file mode 100644 index 00000000..d50586c6 Binary files /dev/null and b/docs/images/05-plan-context-tray.png differ diff --git a/docs/images/05-room-dialog.png b/docs/images/05-room-dialog.png deleted file mode 100644 index 414775ba..00000000 Binary files a/docs/images/05-room-dialog.png and /dev/null differ diff --git a/docs/images/06-device-display-preview.png b/docs/images/06-device-display-preview.png new file mode 100644 index 00000000..eb845281 Binary files /dev/null and b/docs/images/06-device-display-preview.png differ diff --git a/docs/images/06-device-editor.png b/docs/images/06-device-editor.png new file mode 100644 index 00000000..5910d291 Binary files /dev/null and b/docs/images/06-device-editor.png differ diff --git a/docs/images/06-edit.png b/docs/images/06-edit.png deleted file mode 100644 index 455df9d6..00000000 Binary files a/docs/images/06-edit.png and /dev/null differ diff --git a/docs/images/07-background-editor.png b/docs/images/07-background-editor.png new file mode 100644 index 00000000..9735302d Binary files /dev/null and b/docs/images/07-background-editor.png differ diff --git a/docs/images/07-marker-dialog.png b/docs/images/07-marker-dialog.png deleted file mode 100644 index e8976b45..00000000 Binary files a/docs/images/07-marker-dialog.png and /dev/null differ diff --git a/docs/images/08-info.png b/docs/images/08-info.png deleted file mode 100644 index e11c99f1..00000000 Binary files a/docs/images/08-info.png and /dev/null differ diff --git a/docs/images/08-room-card.png b/docs/images/08-room-card.png new file mode 100644 index 00000000..15062db0 Binary files /dev/null and b/docs/images/08-room-card.png differ diff --git a/docs/images/09-device-info.png b/docs/images/09-device-info.png new file mode 100644 index 00000000..67cb0895 Binary files /dev/null and b/docs/images/09-device-info.png differ diff --git a/docs/images/09-zoom.png b/docs/images/09-zoom.png deleted file mode 100644 index 78e1c3b4..00000000 Binary files a/docs/images/09-zoom.png and /dev/null differ diff --git a/docs/images/demo-synthetic-old.gif b/docs/images/demo-synthetic-old.gif deleted file mode 100644 index 6378c61d..00000000 Binary files a/docs/images/demo-synthetic-old.gif and /dev/null differ diff --git a/docs/images/demo.gif b/docs/images/demo.gif deleted file mode 100644 index 75b3813b..00000000 Binary files a/docs/images/demo.gif and /dev/null differ diff --git a/docs/images/manifest.json b/docs/images/manifest.json new file mode 100644 index 00000000..5fd929fb --- /dev/null +++ b/docs/images/manifest.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "fixture": "synthetic-only", + "sourceFingerprint": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "captureScriptSha256": "a0022c98553c5baae8335d7ba26f64129699c4ef6162dd1d88df9f70226ec404", + "command": "npm run docs:capture", + "scenarios": { + "view-desktop": { + "file": "01-view-desktop.png", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0" + }, + "view-touch": { + "file": "02-view-touch.png", + "viewport": { + "width": 390, + "height": 760 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249" + }, + "space-create": { + "file": "03-space-create.png", + "viewport": { + "width": 900, + "height": 850 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b" + }, + "room-contour-close": { + "file": "04-room-contour-close.png", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "59fe5b9df034d255840e7ae6984efb675d39e84ad80dee6f9ad9394c54fb9cda" + }, + "plan-context-tray": { + "file": "05-plan-context-tray.png", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "997bba208897b219051131dc51254ee3c76ed9f73597fde33070ac8cefdcd745" + }, + "device-editor": { + "file": "06-device-editor.png", + "viewport": { + "width": 1180, + "height": 1100 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b" + }, + "device-display-preview": { + "file": "06-device-display-preview.png", + "viewport": { + "width": 1180, + "height": 1100 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167" + }, + "background-editor": { + "file": "07-background-editor.png", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d" + }, + "room-card": { + "file": "08-room-card.png", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11" + }, + "device-info": { + "file": "09-device-info.png", + "viewport": { + "width": 1000, + "height": 900 + }, + "theme": "dark", + "language": "en", + "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a", + "imageSha256": "655873ba709a2a702fde533b33ef109343ce0c6ca85ff985fdd7c4d41c877b33" + } + } +} diff --git a/docs/specs/035-current-ux-docs.md b/docs/specs/035-current-ux-docs.md index d03d6511..c8d59a12 100644 --- a/docs/specs/035-current-ux-docs.md +++ b/docs/specs/035-current-ux-docs.md @@ -1,8 +1,8 @@ # ТЗ #35 — Документация текущего пользовательского опыта - Issue: https://github.com/Matysh/houseplan-card/issues/35 -- Приоритет: P1 -- Статус ТЗ: ready for implementation +- Приоритет: P2 +- Статус ТЗ: implemented - Тип: docs-only, без изменения поведения ## Цель и аудитория diff --git a/package.json b/package.json index 56fe4b9e..e419add4 100755 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "golden:verify": "node demo/golden/run.mjs --mode=verify", "continuity:screencast": "node demo/screencast_visual_continuity.mjs", "golden:accept": "node demo/golden/accept.mjs", + "docs:capture": "npm run build && node demo/docs/capture.mjs", + "docs:check": "node scripts/check-docs.mjs", "release:check": "node scripts/release-prerelease.mjs --check", "release:prerelease": "node scripts/release-prerelease.mjs", "prepare": "node scripts/install-hooks.mjs" diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 00000000..bb24cc47 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, extname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sourceFingerprint } from './source-fingerprint.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const EXTERNAL = process.argv.includes('--external'); +const PUBLIC_DOCS = [ + 'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', + 'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md', +]; +const EXPECTED_SCREENSHOTS = [ + 'view-desktop', 'view-touch', 'space-create', 'room-contour-close', + 'plan-context-tray', 'device-editor', 'device-display-preview', 'background-editor', + 'room-card', 'device-info', +]; +const errors = []; +const warnings = []; +const externalUrls = new Set(); +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n'); + +const withoutFences = (text) => text.replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, ''); +const slug = (heading) => heading + .trim().toLowerCase() + .replace(/<[^>]+>/g, '') + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + +const headingsFor = (path) => { + const seen = new Map(); + const headings = new Set(); + for (const line of canonicalText(path).split('\n')) { + const match = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/); + if (!match) continue; + const base = slug(match[1]); + const count = seen.get(base) || 0; + seen.set(base, count + 1); + headings.add(count ? `${base}-${count}` : base); + } + return headings; +}; + +const rootPrefix = `${ROOT}${sep}`.toLowerCase(); +for (const relative of PUBLIC_DOCS) { + const path = resolve(ROOT, relative); + if (!existsSync(path)) { + errors.push(`${relative}: required public document is missing`); + continue; + } + const text = withoutFences(canonicalText(path)); + const links = /(!?)\[([^\]]*)\]\(([^)]+)\)/g; + for (const match of text.matchAll(links)) { + const image = match[1] === '!'; + const alt = match[2].trim(); + const raw = match[3].trim().replace(/\s+"[^"]*"$/, ''); + if (image && !alt) errors.push(`${relative}: image has empty alt text (${raw})`); + if (/^(?:https?:)?\/\//i.test(raw)) { + try { + const url = new URL(raw.startsWith('//') ? `https:${raw}` : raw); + if (url.protocol !== 'https:') errors.push(`${relative}: external link must use https (${raw})`); + externalUrls.add(url.href); + } catch { + errors.push(`${relative}: invalid external URL (${raw})`); + } + continue; + } + if (/^(?:mailto:|#|\/api\/|\/houseplan_files\/)/.test(raw)) { + if (raw.startsWith('#')) { + const anchor = decodeURIComponent(raw.slice(1)); + if (anchor && !headingsFor(path).has(anchor)) + errors.push(`${relative}: missing local heading #${anchor}`); + } + continue; + } + const hashAt = raw.indexOf('#'); + const filePart = decodeURIComponent(hashAt >= 0 ? raw.slice(0, hashAt) : raw); + const anchor = hashAt >= 0 ? decodeURIComponent(raw.slice(hashAt + 1)) : ''; + const target = resolve(dirname(path), filePart || '.'); + const targetLower = target.toLowerCase(); + if (targetLower !== ROOT.toLowerCase() && !targetLower.startsWith(rootPrefix)) { + errors.push(`${relative}: link escapes repository (${raw})`); + continue; + } + if (!existsSync(target)) { + errors.push(`${relative}: missing relative target (${raw})`); + continue; + } + if (image && !statSync(target).isFile()) errors.push(`${relative}: image target is not a file (${raw})`); + if (anchor && extname(target).toLowerCase() === '.md' && !headingsFor(target).has(anchor)) + errors.push(`${relative}: missing heading ${filePart}#${anchor}`); + } +} + +const sectionMarkers = (relative) => [...canonicalText(resolve(ROOT, relative)) + .matchAll(//g)].map((match) => match[1]); +for (const [en, ru, required] of [ + ['README.md', 'README.ru.md', ['overview', 'features', 'first-run', 'installation', 'support']], + ['docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', ['model', 'installation', 'first-run', 'modes', 'input', 'spaces', 'plan-tools', 'devices', 'visual-states', 'background', 'multiple-cards', 'limits', 'diagnostics']], +]) { + const enMarkers = sectionMarkers(en); + const ruMarkers = sectionMarkers(ru); + if (JSON.stringify(enMarkers) !== JSON.stringify(ruMarkers)) + errors.push(`${en} / ${ru}: docs-section markers differ or are ordered differently`); + for (const marker of required) { + if (!enMarkers.includes(marker)) errors.push(`${en} / ${ru}: missing required section marker ${marker}`); + } +} + +const staleTerms = [ + [/\bMarkup (?:tab|mode|editor)\b/gi, 'Plan'], + [/(?:вкладка|режим|редактор) «Разметка»/gi, '«План»'], + [/\bDecor editor\b/gi, 'Background editor'], +]; +for (const relative of PUBLIC_DOCS.slice(0, 4)) { + const text = withoutFences(canonicalText(resolve(ROOT, relative))); + for (const [pattern, replacement] of staleTerms) { + const matches = text.match(pattern) || []; + if (matches.length) errors.push(`${relative}: stale term “${matches[0]}”; use ${replacement}`); + } +} + +const manifestPath = resolve(ROOT, 'docs/images/manifest.json'); +if (!existsSync(manifestPath)) { + errors.push('docs/images/manifest.json: missing screenshot manifest'); +} else { + const manifest = JSON.parse(canonicalText(manifestPath)); + if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture'); + if (manifest.sourceFingerprint !== sourceFingerprint(ROOT)) errors.push('screenshot source fingerprint is stale; run npm run docs:capture'); + const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs'); + if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath))) + errors.push('screenshot capture script changed; run npm run docs:capture'); + const ids = Object.keys(manifest.scenarios || {}); + if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort())) + errors.push('screenshot manifest scenario set is incomplete'); + for (const [id, scenario] of Object.entries(manifest.scenarios || {})) { + const imagePath = resolve(ROOT, 'docs/images', scenario.file || ''); + if (!existsSync(imagePath)) errors.push(`screenshot ${id}: missing ${scenario.file}`); + else if (scenario.imageSha256 !== sha256(readFileSync(imagePath))) + errors.push(`screenshot ${id}: image hash does not match manifest`); + if (scenario.sourceSha256 !== manifest.sourceFingerprint) + errors.push(`screenshot ${id}: source SHA does not match manifest`); + if (!scenario.viewport?.width || !scenario.viewport?.height || !scenario.theme || !scenario.language) + errors.push(`screenshot ${id}: capture metadata is incomplete`); + } +} + +if (EXTERNAL) { + const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json'))); + const transientHosts = new Set(allowlist.transientHosts || []); + for (const href of [...externalUrls].sort()) { + const url = new URL(href); + try { + const response = await fetch(url, { + method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000), + headers: { 'user-agent': 'houseplan-docs-check/1' }, + }); + if (response.status >= 200 && response.status < 400) continue; + if ([403, 408, 425, 429].includes(response.status) || response.status >= 500) { + if (transientHosts.has(url.hostname)) { + warnings.push(`transient external response ${response.status}: ${href}`); + continue; + } + } + errors.push(`external link returned ${response.status}: ${href}`); + } catch (error) { + if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`); + else errors.push(`external link failed: ${href} (${error.message})`); + } + } +} + +for (const warning of warnings) console.warn(`WARN ${warning}`); +if (errors.length) { + for (const error of errors) console.error(`ERROR ${error}`); + process.exitCode = 1; +} else { + console.log(`Documentation checks passed (${PUBLIC_DOCS.length} files, ${externalUrls.size} external links).`); +}