mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ff2acb7a |
@@ -1,36 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something in House Plan does not work as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: House Plan version
|
||||
placeholder: v1.13.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: ha_version
|
||||
attributes:
|
||||
label: Home Assistant version
|
||||
placeholder: "2026.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened / what did you expect?
|
||||
description: Steps to reproduce help a lot.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / browser console errors
|
||||
description: "Settings → System → Logs (search: houseplan) and the browser console (F12)."
|
||||
render: text
|
||||
- type: textarea
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
description: "Settings → Devices & services → House Plan → ⋯ → Download diagnostics (personal fields are redacted automatically)."
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -1,14 +0,0 @@
|
||||
name: Feature request
|
||||
description: An idea to make House Plan better
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem would this solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: How do you imagine it working?
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Validate
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
jobs:
|
||||
hacs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
hassfest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
smoke:
|
||||
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||
# report green about code that no longer exists (audit T2)
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
fail=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
for f in demo/smoke_*.mjs; do
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
@@ -1,27 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
- **Owner's local Windows checkout is invisible here.** Path
|
||||
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
|
||||
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
|
||||
expect to `ls` or diff that folder. To bring local work into the cloud
|
||||
agent: push a branch to GitHub and say its name, or run a **local** Cursor
|
||||
Agent / My Machines worker inside that checkout. Day-to-day source of truth
|
||||
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
|
||||
(releases) — see `docs/STATUS.md`.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Contributing to House Plan
|
||||
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
npm ci # frontend toolchain
|
||||
npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
```
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
|
||||
every push — locally they are skipped when `homeassistant` is not importable.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
|
||||
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
|
||||
- Follow the Integration Quality Scale where applicable —
|
||||
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
|
||||
|
||||
## Architecture
|
||||
|
||||
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
|
||||
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
|
||||
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
|
||||
the workflow attaches the card bundle.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"source": "674e589ad841c6580f31d1a495bb207f13e6931e",
|
||||
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
|
||||
"files": 30,
|
||||
"builtAt": "2026-09-29T06:30:42.564Z"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JB (justbusiness)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,338 +0,0 @@
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 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.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **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 casts a pool
|
||||
of its own color that spills through doorways and open zone boundaries.
|
||||
- ☀️ **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. 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.
|
||||
|
||||
---
|
||||
|
||||
## What it is and why
|
||||
|
||||
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.
|
||||
|
||||
This is convenient when:
|
||||
|
||||
- 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:
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## How it differs from alternatives
|
||||
|
||||
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:
|
||||
|
||||
| | 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 |
|
||||
|
||||
**One sentence:** House Plan is the shared, area-aware live map of your home —
|
||||
not a drawing app for furniture. If you want to sketch sofas and walls from
|
||||
scratch, a draw-centric card may fit better; if you already have a plan image
|
||||
and a real HA areas registry, House Plan keeps every tablet on the same layout.
|
||||
|
||||
Key advantages in short:
|
||||
|
||||
- **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, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). 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.
|
||||
- **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:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Via HACS (recommended)
|
||||
|
||||
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**.
|
||||
|
||||
The card is registered automatically — no need to add a Lovelace resource manually.
|
||||
|
||||
> **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.
|
||||
|
||||
### Manually
|
||||
|
||||
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**.
|
||||
|
||||
### Adding a plan screen
|
||||
|
||||
Create a new dashboard tab (a "Panel" view works best) and add the card:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: House plan
|
||||
```
|
||||
|
||||
Nothing else needs to be specified — everything else is configured right on the screen.
|
||||
|
||||
---
|
||||
|
||||
## How to use
|
||||
|
||||
### Step 1. Add a space (floor)
|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
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 and can be moved and resized at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
> 💡 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.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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 and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: 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), an open/close sensor and — for doors — 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 door 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 **"Show hidden"**: 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.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
By default a tap on an icon opens its info card. In the card settings you can switch
|
||||
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
|
||||
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
|
||||
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
|
||||
specific device (except locks and alarms — those never toggle from the plan) in its
|
||||
edit dialog. A **long press** always opens the info card.
|
||||
|
||||
### 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**.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
|
||||
-338
@@ -1,338 +0,0 @@
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Что это и зачем
|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
|
||||
Это удобно, когда:
|
||||
|
||||
- устройств много, и списками пользоваться неудобно;
|
||||
- нужно быстро понять состояние дома «одним взглядом»;
|
||||
- хочется отдать доступ близким — по картинке разберётся любой;
|
||||
- вы хотите красивый обзорный экран для настенного планшета.
|
||||
|
||||
Интеграция состоит из двух частей, которые ставятся вместе:
|
||||
|
||||
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
|
||||
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
|
||||
|
||||
---
|
||||
|
||||
## Чем отличается от аналогов
|
||||
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||
|
||||
| | 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 / виртуальный холст |
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
|
||||
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
|
||||
и тот же layout на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с 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:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
|
||||
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
|
||||
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
|
||||
|
||||
Карточка подключается автоматически — добавлять ресурс 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`) лежат в этом одном файле — отдельный ресурс
|
||||
> не нужен.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
### Добавление экрана с планом
|
||||
|
||||
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: План дома
|
||||
```
|
||||
|
||||
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
|
||||
|
||||
---
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
### Шаг 1. Добавьте пространство (этаж)
|
||||
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
|
||||
|
||||
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
|
||||
|
||||
### Шаг 2. Обведите комнаты
|
||||
|
||||
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
|
||||
|
||||
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
|
||||
|
||||

|
||||
|
||||
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||
|
||||
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||
|
||||
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см), датчик открытия и — для двери — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
|
||||
стен** (в том числе за угол), а **двойной клик открывает свойства**.
|
||||
|
||||
### Шаг 3. Устройства появляются сами
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||

|
||||
|
||||
### Шаг 4. Масштаб
|
||||
|
||||
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
|
||||
|
||||

|
||||
|
||||
### Шаг 5. Расставьте значки по местам
|
||||
|
||||
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
|
||||
|
||||

|
||||
|
||||
### Управление с плана (tap actions)
|
||||
|
||||
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
|
||||
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
|
||||
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
|
||||
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
|
||||
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
|
||||
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
|
||||
нажатие** всегда открывает инфо-карточку.
|
||||
|
||||
### Правила иконок
|
||||
|
||||
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
|
||||
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
|
||||
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
|
||||
правило не подошло — решает *device class* сущности (термометр для датчиков
|
||||
температуры и т.п.).
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
|
||||
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
|
||||
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
|
||||
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
|
||||
может быть маленьким и повёрнутым так, как он установлен.
|
||||
|
||||

|
||||
|
||||
### Свои стили через 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.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
|
||||
@@ -1,275 +0,0 @@
|
||||
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FILES_URL,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
|
||||
data = create_data(hass)
|
||||
# test-before-setup: storage must be readable, otherwise retry later
|
||||
try:
|
||||
await data.store.async_load()
|
||||
await data.config_store.async_load()
|
||||
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
await recorder.async_setup()
|
||||
# setdefault: the CI harness sets entries up without async_setup, so
|
||||
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
|
||||
# downstream WS test with unknown_error
|
||||
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
await hass.async_add_executor_job(
|
||||
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
their handlers resolve runtime data per call and answer `not_ready` while no
|
||||
entry is loaded. Static paths cannot be unregistered by design.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
|
||||
# UI has always been admin-gated, and an unset option must not open every
|
||||
# write WS/HTTP path to every authenticated household user.
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
|
||||
return is_admin if admin_only else True
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
@@ -1,40 +0,0 @@
|
||||
"""Config flow: a single entry with no parameters."""
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
||||
|
||||
|
||||
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""One-step setup."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="House Plan", data={}, options=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
return HouseplanOptionsFlow()
|
||||
|
||||
|
||||
class HouseplanOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Option: layout editing by administrators only."""
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
|
||||
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Constants of the House Plan integration."""
|
||||
|
||||
DOMAIN = "houseplan"
|
||||
STORAGE_KEY = f"{DOMAIN}.layout"
|
||||
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 1
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.59.0-beta.5"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Diagnostics for House Plan (Settings → ... → Download diagnostics)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
# Marker metadata may contain personal notes, external links and manual filenames.
|
||||
TO_REDACT = {"link", "description", "pdfs", "name"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return a redacted dump of the stores."""
|
||||
data = entry.runtime_data
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
layout = layout_raw.get("layout", {})
|
||||
return {
|
||||
"options": dict(entry.options),
|
||||
"rev": cfg_raw.get("rev", 0),
|
||||
"spaces": [
|
||||
{
|
||||
"id": s.get("id"),
|
||||
"aspect": s.get("aspect"),
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
"markers": async_redact_data(config.get("markers", []), TO_REDACT),
|
||||
"settings": config.get("settings", {}),
|
||||
"layout_entries": len(layout),
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 300323,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 301845,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2325,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
],
|
||||
"lazyGzipBytes": 456389,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 238192,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 27950,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 56330,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 17885,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 16940,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 126938,
|
||||
"lazyNamespaceLocaleFiles": [
|
||||
"houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"houseplan-assets/settings-ru-CkNFmChi.js",
|
||||
"houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"houseplan-assets/topology-ru-BRKShmrQ.js"
|
||||
],
|
||||
"lazyNamespaceLocaleGzipBytes": 21057,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"sha256": "5f6dcedf274594075d8acd31295d8c23f6d1408c890c086825db6bcbaa6c9b21",
|
||||
"rawBytes": 54017,
|
||||
"gzipBytes": 17576,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"houseplan-assets/settings-ru-CkNFmChi.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-E17v3gXt.js",
|
||||
"sha256": "ed10d6c5e7a82b275c9422be67c2213219a75dba9aab0ddb3e98813249cbd1d0",
|
||||
"rawBytes": 94196,
|
||||
"gzipBytes": 28393,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CbeYCXUv.js",
|
||||
"sha256": "75887638c4d76040651a3cf52c56de10dc19e0cbc7583ee169d9b20cc5987788",
|
||||
"rawBytes": 3833,
|
||||
"gzipBytes": 1584,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"sha256": "cdab9e5e612be9b98c49193a2a6287080c13f62aaefa9a28b69bac94dd934012",
|
||||
"rawBytes": 25600,
|
||||
"gzipBytes": 5056,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"sha256": "ee9fe5934eea69e549ff48d2e3fdabd7c20e0d9906a6f4623314f6eea48bf269",
|
||||
"rawBytes": 96627,
|
||||
"gzipBytes": 27937,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"sha256": "f668d3c0a47a53e096f26728187b1666fbb6b421c1baa2ff1a649fa1ba5340d5",
|
||||
"rawBytes": 52035,
|
||||
"gzipBytes": 16940,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"sha256": "055b6f8ba3a472be163a15ef6e2348727b5b03e3660b9362f3aed21d7be90426",
|
||||
"rawBytes": 1059790,
|
||||
"gzipBytes": 299520,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"sha256": "f25cbcb0453279e3ac64eda67df52af100d96c54bb93cb657bc08c355e0484ed",
|
||||
"rawBytes": 688848,
|
||||
"gzipBytes": 192327,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"sha256": "4ea3584c8b2bfc86f96c587549ddce49eaf0b918923df2df8e98c9c0a548f835",
|
||||
"rawBytes": 15886,
|
||||
"gzipBytes": 4908,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"sha256": "2a0f9af108b8a39889f90d7119d8e7789716c9960ea8e28af38ada07cdc91a8c",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 3765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"sha256": "4150eb9120fdaafa745761242ff3285056ceaccd7b016a16364574b90e3e2cfe",
|
||||
"rawBytes": 52970,
|
||||
"gzipBytes": 17885,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"sha256": "66e70baa00f4c9470ac9a37a229652cf5c5585caa2364de4d467a1c2ebe24358",
|
||||
"rawBytes": 8370,
|
||||
"gzipBytes": 3280,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"sha256": "244bce2d5211106846215be6a9d3dacda4c910d06020144f9ffd5e432c5e8c00",
|
||||
"rawBytes": 675,
|
||||
"gzipBytes": 410,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"sha256": "548dced1a8a54f860dbe57e61019d50f058d3ca13abb738d750a3c107c88de8b",
|
||||
"rawBytes": 236714,
|
||||
"gzipBytes": 109998,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"sha256": "0580b9f6191edb664c21dee21e5263489eaf22876bcad67ef12e440abc03e28a",
|
||||
"rawBytes": 7378,
|
||||
"gzipBytes": 2879,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"sha256": "f1bde9db7fe884aabd25130f783a84d8fe27bc9553923c17bcb9b82ee3976475",
|
||||
"rawBytes": 7620,
|
||||
"gzipBytes": 2842,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-ru-CkNFmChi.js",
|
||||
"sha256": "94f96c4a15a3a82ca8be3066629734379dd14eb2ff9e8f6acf369da2378676cb",
|
||||
"rawBytes": 10304,
|
||||
"gzipBytes": 3325,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"sha256": "d45dbdfa17a1da9f45bfbca6d6c3cc98849fd5ebcc06c8f72fd6310b04b93aad",
|
||||
"rawBytes": 14862,
|
||||
"gzipBytes": 3425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-D47J-OjO.js",
|
||||
"sha256": "909d2195fcfca30c2f1279e07e2d7506d470c3daec6fe864c97f7ecc4101e33d",
|
||||
"rawBytes": 2701,
|
||||
"gzipBytes": 1344,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
|
||||
"sha256": "e44860a1af9ec7ffc7ac289c8c76ed1d0dcf0177bd91bbfe1981fdd0d4a62c46",
|
||||
"rawBytes": 72959,
|
||||
"gzipBytes": 20242,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"houseplan-assets/summary-panel-metrics-D47J-OjO.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"sha256": "f01d2d00a169519f5f402e99e9c85c9f21a23aeee2a9605d4444910f3250d6de",
|
||||
"rawBytes": 6167,
|
||||
"gzipBytes": 2396,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"sha256": "e503a1b4e1b1ed31d8fa09bfe0cdc069a82c1d1627d40407472b336fbc98ccf5",
|
||||
"rawBytes": 6333,
|
||||
"gzipBytes": 2432,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"sha256": "76742313c4b510163c380f8fd9c7fcaf1cc7f8bde6a41bcb0da48621a7c3a3b3",
|
||||
"rawBytes": 8376,
|
||||
"gzipBytes": 2876,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"sha256": "7c808f1139bb1c232627bfe884a579177ec653d16147d6b96066851893b312a4",
|
||||
"rawBytes": 2698,
|
||||
"gzipBytes": 1337,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"sha256": "6ee1530fd0bd7a8c476c79b83404c7d8a5b98cd0b86ee8a73048be75a70cc2c4",
|
||||
"rawBytes": 2718,
|
||||
"gzipBytes": 1354,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-ru-BRKShmrQ.js",
|
||||
"sha256": "7fc89542fa860318d459ce1467a5a8931f3214a9d17f57c177ba01f2cfbbd3e8",
|
||||
"rawBytes": 3669,
|
||||
"gzipBytes": 1616,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"sha256": "313f71677ba0739d9c978185c2136e73e8037b770eb3f9ef8561f692d654406f",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 4299,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"houseplan-assets/topology-ru-BRKShmrQ.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CKlry6R8.js",
|
||||
"sha256": "39e4a250de0e67e80d8afc02c39c1a79e42d4b3e113107bb3817f4fa90cab1e2",
|
||||
"rawBytes": 3983,
|
||||
"gzipBytes": 1789,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "d4b480db1327152db7b1459c45692ea6b72fb7ac3bccb525bc06adfe1aab3b8d",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 803,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "5aaa677a9c85f4df0f176be31a91ebb5022f5ce129774919269bb0409c310233",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2325,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{aD as e,l as o,aG as t,ez as s,A as a,eA as i,eB as l,eC as n,E as r,c}from"./houseplan-card-BYwTiVnu.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2560
File diff suppressed because one or more lines are too long
+44
File diff suppressed because one or more lines are too long
+33
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eE as e,eF as n,eB as a}from"./houseplan-card-BYwTiVnu.js";function c(a,c){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",console.warn,n)}function o(e){return function(e,n){return{state:a=>{const c=e.state(a);return"pending"!==c&&n.some(e=>"pending"===e.state(a))?"pending":c},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{c as n,o as s};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+203
@@ -0,0 +1,203 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{c as a,A as e}from"./houseplan-card-BYwTiVnu.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-B0Dq0vzN.js";import"./form-kit.styles-v5DlO12L.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
.checked=${d.localShow}
|
||||
?disabled=${d.busy} aria-label=${b("summary.show_local")}
|
||||
aria-describedby="summary-local-show-hint"
|
||||
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
|
||||
.title=${b("summary.settings")} wide flex-content
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
|
||||
<section class="summary-general" aria-labelledby="summary-general-title">
|
||||
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
|
||||
<div class="summary-general-grid">
|
||||
${d.localOnly?e:a`<div class="summary-field">
|
||||
<label for="summary-panel-title">${b("summary.panel_title")}</label>
|
||||
<input id="summary-panel-title" type="text" .value=${d.draft.title}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&"title"===y?.path)}
|
||||
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
|
||||
</div>`}
|
||||
${g}
|
||||
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
|
||||
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
|
||||
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
|
||||
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
|
||||
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
|
||||
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
|
||||
</div>
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
|
||||
</section>
|
||||
${d.localOnly?e:a`
|
||||
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
|
||||
<header class="summary-blocks-heading">
|
||||
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
|
||||
<p>${b("summary.blocks_hint")}</p></div>
|
||||
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
|
||||
</header>
|
||||
<div class="summary-editor-blocks">
|
||||
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
@dragover=${a=>a.preventDefault()}
|
||||
@drop=${a=>m.drop(a,`block:${t}`)}>
|
||||
<div class="summary-block-head">
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@dragstart=${a=>m.dragStart(a,`block:${t}`)}>${l("grip")}</span>
|
||||
<div class="summary-order">
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.up")}
|
||||
aria-label=${b("summary.up")} ?disabled=${0===t||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${t===d.draft.blocks.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<input class="summary-block-title" type="text" .value=${s.title}
|
||||
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
|
||||
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
|
||||
<button class="summary-icon-button summary-visibility" type="button"
|
||||
?disabled=${d.busy} aria-pressed=${String(s.visible)}
|
||||
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
|
||||
<div class="summary-scope">
|
||||
<select .value=${s.scope.type} ?disabled=${d.busy}
|
||||
aria-label=${b("summary.scope")}
|
||||
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
|
||||
</select>
|
||||
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
|
||||
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
|
||||
aria-invalid=${"error"===n?.kind?"true":"false"}
|
||||
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
|
||||
${c._model.map(e=>a`<option value=${e.id}
|
||||
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
|
||||
</select>`:e}
|
||||
</div>
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
|
||||
</div>
|
||||
<div class="summary-block-body">
|
||||
<div class="summary-editor-values">
|
||||
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
|
||||
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@dragstart=${a=>m.dragStart(a,`value:${t}:${c}`)}>${l("grip")}</span>
|
||||
<div class="summary-order">
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.up")}
|
||||
aria-label=${b("summary.up")} ?disabled=${0===c||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${c===s.values.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<div class="summary-value-label">
|
||||
<input type="text" .value=${i.label} ?disabled=${d.busy}
|
||||
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
|
||||
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
|
||||
</div>
|
||||
<div class="summary-source-field">
|
||||
<button class="summary-source" type="button"
|
||||
?disabled=${d.busy}
|
||||
data-summary-source-owner=${S}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
|
||||
aria-invalid=${"error"===$?.kind?"true":"false"}
|
||||
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
|
||||
<span class="summary-source-caption"><strong>${g}</strong>
|
||||
${_?a`<small>${_}</small>`:e}</span>
|
||||
${l("chevron")}
|
||||
</button>
|
||||
${k&&f?a`<div class="summary-source-picker"
|
||||
@click=${a=>a.stopPropagation()}
|
||||
@keydown=${a=>{"Escape"===a.key&&(a.preventDefault(),a.stopPropagation(),m.closeSource(!0))}}>
|
||||
<label>${b("summary.search_entities")}</label>
|
||||
<input type="search" data-summary-picker-search
|
||||
?disabled=${d.busy}
|
||||
aria-label=${b("summary.search_entities")} .value=${d.entityFilter}
|
||||
@input=${a=>m.setDialog({...d,entityFilter:a.target.value})} />
|
||||
<div class="summary-source-results" role="listbox"
|
||||
aria-label=${b("summary.select_source")}>
|
||||
<div class="summary-source-group">${b("summary.system_group")}</div>
|
||||
${["device_count","total_area","datetime"].map(e=>a`
|
||||
<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`system:${e}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
|
||||
${b(`summary.system.${e}`)}
|
||||
</button>`)}
|
||||
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
|
||||
<button type="button" role="option" class="broken" aria-selected="true"
|
||||
?disabled=${d.busy}
|
||||
@click=${()=>m.setSource(s.id,i.id,p)}>
|
||||
${g}
|
||||
</button>`:e}
|
||||
<div class="summary-source-group">${b("summary.entities_group")}</div>
|
||||
${f.entries.map(e=>a`<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`entity:${e.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
|
||||
<span>${e.label}</span><small>${e.id}</small>
|
||||
</button>`)}
|
||||
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
|
||||
</div>
|
||||
</div>`:e}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
|
||||
</div>
|
||||
<button class="summary-icon-button summary-value-remove" type="button"
|
||||
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
|
||||
</div>`})}
|
||||
<button class="summary-add summary-add-value" type="button"
|
||||
?disabled=${s.values.length>=20||d.busy}
|
||||
title=${s.values.length>=20?b("summary.limit_values"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
|
||||
${l("plus")}${b("summary.add_value")}
|
||||
</button>
|
||||
</div>
|
||||
<footer class="summary-block-footer">
|
||||
<button class="summary-delete-block" type="button" ?disabled=${d.busy}
|
||||
@click=${()=>m.deleteBlock(t)}>${b("summary.delete_block")}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</article>`})}
|
||||
</div>
|
||||
<button class="summary-add summary-add-block" type="button"
|
||||
?disabled=${d.draft.blocks.length>=10||d.busy}
|
||||
title=${d.draft.blocks.length>=10?b("summary.limit_blocks"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks.push(u(b))})}>
|
||||
${l("plus")}${b("summary.add_block")}
|
||||
</button>
|
||||
</section>
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
|
||||
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
|
||||
?disabled=${d.busy} @click=${()=>m.reload()}>
|
||||
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
|
||||
</button>`:e}
|
||||
`}
|
||||
</div>
|
||||
<div class="row summary-editor-footer" slot="footer">
|
||||
<button class="btn ghost" data-hp="dialog-cancel"
|
||||
?disabled=${d.busy} @click=${k}>${b("btn.cancel")}</button>
|
||||
<button class="btn on" data-hp="dialog-confirm"
|
||||
?disabled=${d.localOnly?!$||d.busy:!p}
|
||||
@click=${()=>m.save()}>${b("btn.save")}</button>
|
||||
</div>
|
||||
</hp-dialog>`};export{m as renderSummaryPanelEditor};
|
||||
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eH as e,N as t,b2 as n,L as i,ae as o,ac as r,c9 as s,eI as a,c8 as c,b1 as l,b0 as d,b3 as u,eJ as f,eK as m,cb as g,eL as v}from"./houseplan-card-BYwTiVnu.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
|
||||
+272
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,n as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,s as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const t="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,t as fingerprint};
|
||||
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{n as t,c as n}from"./zigbee-topology-DOAnbvlu.js";import{aM as e}from"./houseplan-card-BYwTiVnu.js";import"./namespace-language-zIuNvgGV.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
Executable → Regular
+1
-4045
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,161 +0,0 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -1,255 +0,0 @@
|
||||
"""HTTP endpoint for uploading House Plan manual files.
|
||||
|
||||
Files (PDF and the like) are uploaded not over WebSocket (its message size limit
|
||||
breaks the connection on a large PDF) but via a plain multipart POST — like media in HA itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
file_ext,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
The directories used to be exposed as unauthenticated static paths, so
|
||||
anyone who could reach the HA endpoint could pull floor plans and uploaded
|
||||
manuals without logging in. This view keeps the same URLs but requires a
|
||||
Home Assistant session (or a signed path, which the frontend uses for
|
||||
<image href> inside the SVG).
|
||||
"""
|
||||
|
||||
url = "/api/houseplan/content/{kind}/{sub}/{name}"
|
||||
name = "api:houseplan:content"
|
||||
requires_auth = True
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
|
||||
url = "/api/houseplan/upload"
|
||||
name = "api:houseplan:upload"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"frontend",
|
||||
"websocket_api"
|
||||
],
|
||||
"documentation": "https://github.com/Matysh/houseplan-card",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.59.0-beta.5"
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -1,112 +0,0 @@
|
||||
# Integration Quality Scale self-assessment.
|
||||
# Custom integrations are not formally graded (they sit in the "Custom" tier),
|
||||
# but we track the official checklist here. done = implemented, exempt = not
|
||||
# applicable with the reason.
|
||||
rules:
|
||||
# ---- Bronze ----
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: The integration registers no service actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: No polling — storage + WebSocket API + frontend serving only.
|
||||
brands:
|
||||
status: done
|
||||
comment: Local brand images in custom_components/houseplan/brand/ (HA >=2026.3 mechanism).
|
||||
common-modules:
|
||||
status: done
|
||||
comment: const.py, store.py (stores + runtime data), validation.py (pure schemas).
|
||||
config-flow:
|
||||
status: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
|
||||
dependency-transparency:
|
||||
status: done
|
||||
comment: No external requirements.
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No service actions.
|
||||
docs-high-level-description:
|
||||
status: done
|
||||
comment: README.md.
|
||||
docs-installation-instructions:
|
||||
status: done
|
||||
comment: README.md (HACS + manual).
|
||||
docs-removal-instructions:
|
||||
status: done
|
||||
comment: README.md uninstall section.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
runtime-data:
|
||||
status: done
|
||||
comment: entry.runtime_data holds HouseplanData (stores + write lock).
|
||||
test-before-configure:
|
||||
status: exempt
|
||||
comment: No external device/service to validate during the flow.
|
||||
test-before-setup:
|
||||
status: done
|
||||
comment: Storage load is verified in async_setup_entry (ConfigEntryNotReady on failure).
|
||||
unique-config-entry:
|
||||
status: done
|
||||
comment: single_config_entry in manifest.
|
||||
# ---- Silver ----
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No service actions; WS handlers reply with typed error codes.
|
||||
config-entry-unloading:
|
||||
status: done
|
||||
comment: Unload supported; WS commands and static paths are global by design (documented in __init__).
|
||||
docs-configuration-parameters:
|
||||
status: done
|
||||
comment: README documents the admin_only option and card options.
|
||||
docs-installation-parameters:
|
||||
status: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
integration-owner:
|
||||
status: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: No external service.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: No entities/polling.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication against an external service.
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
|
||||
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
|
||||
diagnostics:
|
||||
status: done
|
||||
comment: diagnostics.py with redaction of personal marker fields.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: Nothing to reconfigure — no host/credentials; all data is edited in the card UI.
|
||||
repair-issues:
|
||||
status: done
|
||||
comment: Missing plan files raise repair issues (translation_key broken_plan).
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
docs-examples:
|
||||
status: todo
|
||||
# ---- Platinum ----
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Python is annotated; mypy strict pass is planned.
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: No dependencies.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: No outgoing HTTP.
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Repair issues for House Plan.
|
||||
|
||||
The check runs at entry setup AND after every config save (ws_config_set),
|
||||
so a plan file that goes missing — or gets re-uploaded — is reflected in the
|
||||
Repairs UI without waiting for a restart.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
|
||||
async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
||||
"""Raise an issue for every space whose plan file is missing on disk."""
|
||||
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
||||
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _missing() -> list[tuple[str, str]]:
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
# both the legacy static URL and the authenticated content URL
|
||||
prefix = None
|
||||
if url.startswith(PLANS_URL + "/"):
|
||||
prefix = PLANS_URL + "/"
|
||||
elif url.startswith(CONTENT_URL + "/plans/_/"):
|
||||
prefix = CONTENT_URL + "/plans/_/"
|
||||
if prefix is None:
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(prefix) :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
|
||||
missing = await hass.async_add_executor_job(_missing)
|
||||
broken = set()
|
||||
for space_id, fname in missing:
|
||||
broken.add(space_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"broken_plan_{space_id}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Storage helpers: versioned stores and per-entry runtime data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
|
||||
|
||||
|
||||
class HouseplanStore(Store):
|
||||
"""Store with a migration hook.
|
||||
|
||||
Bump STORAGE_MINOR_VERSION for backward-compatible schema additions and
|
||||
STORAGE_VERSION for breaking changes, then handle them here. Keeping the
|
||||
skeleton in place from day one means old installations always pass through
|
||||
a single, tested upgrade path.
|
||||
"""
|
||||
|
||||
async def _async_migrate_func(
|
||||
self,
|
||||
old_major_version: int,
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
data = old_data
|
||||
# if old_major_version == 1 and old_minor_version < 2:
|
||||
# ...migrate...
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
class HouseplanData:
|
||||
"""Runtime data of the single config entry (entry.runtime_data)."""
|
||||
|
||||
store: HouseplanStore
|
||||
config_store: HouseplanStore
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
|
||||
def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
"""Create the stores for a config entry."""
|
||||
return HouseplanData(
|
||||
store=HouseplanStore(hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_MINOR_VERSION),
|
||||
config_store=HouseplanStore(
|
||||
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_data(hass: HomeAssistant) -> HouseplanData | None:
|
||||
"""Runtime data of the loaded entry, or None when not set up."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return entries[0].runtime_data if entries else None
|
||||
|
||||
|
||||
def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
"""The loaded config entry, or None."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return entries[0] if entries else None
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
}
|
||||
},
|
||||
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"""System health for House Plan (Settings → System → Repairs → System information)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
|
||||
"""Register the system health info callback."""
|
||||
register.async_register_info(system_health_info)
|
||||
|
||||
|
||||
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Return integration health info."""
|
||||
data = get_data(hass)
|
||||
if data is None:
|
||||
return {"status": "not set up"}
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"""Server-side vacuum trails.
|
||||
|
||||
The integration records the robot's path ITSELF by watching the source
|
||||
entity's state changes — no card involvement. This removes every client-side
|
||||
race (N open tabs would fight over writes), survives page reloads by
|
||||
construction, and keeps recording while no card is open at all. Stored: the
|
||||
current run and one previous run per marker (owner call 2026-07-31 — users
|
||||
want to see where the cleanup has already been).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
|
||||
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
|
||||
value that is not None wins — truthiness is wrong here: a zero-based
|
||||
`map_index: 0` is a valid first map and an empty string is still an id.
|
||||
The old `or`-chain dropped the zero, so the server stored trails under a
|
||||
key the renderer never looked up (HP-1540-02).
|
||||
"""
|
||||
for v in (
|
||||
src_attrs.get("map_name"),
|
||||
src_attrs.get("current_map"),
|
||||
src_attrs.get("map_index"),
|
||||
src_attrs.get("selected_map"),
|
||||
vac_attrs.get("selected_map"),
|
||||
):
|
||||
if v is not None:
|
||||
return str(v)
|
||||
return "default"
|
||||
|
||||
|
||||
class TrailBook:
|
||||
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
|
||||
|
||||
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
|
||||
robot coordinates — recalibration never invalidates a stored trail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
half = pts[0::2]
|
||||
if half[-1] != pts[-1]:
|
||||
half.append(pts[-1])
|
||||
cur["points"] = half
|
||||
return True
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
self.book = TrailBook()
|
||||
# HP-1540-03: one source may feed SEVERAL markers — the same robot
|
||||
# placed on two floors is the documented multi-floor case, and a plain
|
||||
# source → (marker, vacuum) dict silently kept only the last one
|
||||
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# HP-1540-05: config/set fires refresh as a detached task; two of them
|
||||
# interleaving across the awaited load both subscribed and the loser's
|
||||
# unsub handle was overwritten — a leak until HA restart
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
self.book = TrailBook(await self.store.async_load() or {})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""(Re)subscribe after any config change — markers may come and go.
|
||||
|
||||
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
|
||||
atomic across the awaited config load, so overlapping refresh tasks can
|
||||
no longer both subscribe and strand one callback forever. The _closed
|
||||
check covers teardown() racing a refresh that is parked on its await.
|
||||
"""
|
||||
async with self._refresh_lock:
|
||||
stored = await self.rt.config_store.async_load() or {}
|
||||
if self._closed:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((str(m.get("id")), vac))
|
||||
self.pairs = pairs
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
# lost.
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
def teardown(self) -> None:
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
self._closed = True
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
if b.startswith("entity:vacuum."):
|
||||
return b[len("entity:"):]
|
||||
if b.startswith("device:"):
|
||||
reg = er.async_get(self.hass)
|
||||
for e in er.async_entries_for_device(reg, b[len("device:"):]):
|
||||
if e.entity_id.startswith("vacuum."):
|
||||
return e.entity_id
|
||||
return None
|
||||
|
||||
def _sample(self, src: str, now: float) -> bool:
|
||||
"""Record one point (or end the run) for EVERY marker fed by src.
|
||||
|
||||
HP-1540-03: the same source serves one marker per floor — all of them
|
||||
must receive the point, not just whichever survived the dict.
|
||||
"""
|
||||
changed = False
|
||||
for marker, vac in self.pairs.get(src) or ():
|
||||
st_vac = self.hass.states.get(vac)
|
||||
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
|
||||
# unavailable and ending the run here would split one cleanup into
|
||||
# current+previous on every restart (observed live: 21 points
|
||||
# became previous, the same run restarted at 5)
|
||||
if not st_vac or st_vac.state in ("unavailable", "unknown"):
|
||||
continue
|
||||
if st_vac.state not in MOVING_STATES:
|
||||
changed |= self.book.end_run(marker, now)
|
||||
continue
|
||||
st_src = self.hass.states.get(src)
|
||||
attrs = st_src.attributes if st_src else {}
|
||||
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
|
||||
# Server-side these attributes are often OBJECTS (Tasshack keeps a
|
||||
# Point dataclass in memory — it only becomes a dict when
|
||||
# serialised to the frontend). Caught live on the owner's X50: the
|
||||
# recorder saw every state change and rejected every single one.
|
||||
if isinstance(raw, dict):
|
||||
px, py = raw.get("x"), raw.get("y")
|
||||
else:
|
||||
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
|
||||
try:
|
||||
x, y = float(px), float(py) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
return changed
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
now = time.time()
|
||||
changed = False
|
||||
for src, pair_list in self.pairs.items():
|
||||
if eid == src or any(eid == vac for _, vac in pair_list):
|
||||
changed |= self._sample(src, now)
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
self._last_fire = now
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._unsub_save:
|
||||
return
|
||||
|
||||
async def _save(_now: Any) -> None:
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Only administrators may edit the layout"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Only administrators may edit the layout"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Правка раскладки только администраторами"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Уже настроено — допускается одна запись."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Правка раскладки только администраторами"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
"""Pure House Plan validation and sanitizers — no Home Assistant dependencies.
|
||||
|
||||
Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
|
||||
MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
|
||||
def sanitize_marker_id(value: str) -> str:
|
||||
"""Safe marker identifier for a folder name.
|
||||
|
||||
Strips path separators and leading dots to rule out directory traversal
|
||||
(e.g. '..', '../x'); an empty/dots-only result → 'misc'.
|
||||
"""
|
||||
cleaned = _SAFE_NAME_RE.sub("_", value).lstrip(".")[:64]
|
||||
return cleaned or "misc"
|
||||
|
||||
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
"""Lowercase file extension ('' if none)."""
|
||||
raw = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return raw.rsplit(".", 1)[-1].lower() if "." in raw else ""
|
||||
|
||||
|
||||
def valid_space_id(value: str) -> bool:
|
||||
return bool(SPACE_ID_RE.match(value))
|
||||
|
||||
|
||||
# ---------- voluptuous schemas ----------
|
||||
def _finite(value):
|
||||
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||
|
||||
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||
silently corrupting a stored position forever.
|
||||
"""
|
||||
f = float(value)
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
raise vol.Invalid("coordinate must be a finite number")
|
||||
return f
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 500
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
PLAN_SCALE_MIN = 0.01
|
||||
PLAN_SCALE_MAX = 100.0
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
|
||||
return room
|
||||
raise vol.Invalid("room: poly or x/y/w/h is required")
|
||||
|
||||
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
|
||||
def _north_deg(value):
|
||||
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
|
||||
|
||||
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
|
||||
int in Python — none of those is a compass reading a client stored.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
if not 0 <= value <= 359:
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
return value
|
||||
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# per-space background around the plan; absent = inherit the global one
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
# "draw less" switches; absent = False = everything is drawn as before
|
||||
vol.Optional("hide_decor"): bool,
|
||||
vol.Optional("hide_openings"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
vol.Optional("label_light"): bool,
|
||||
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
|
||||
vol.Optional("north_deg"): vol.Any(None, _north_deg),
|
||||
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
|
||||
vol.Optional("sun_rays"): vol.Any(None, bool),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
|
||||
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
|
||||
# A caption is a caption: the template is bounded, the attribute name is a flat
|
||||
# name (no dots needed), and a unit is a couple of characters, not a sentence.
|
||||
MAX_DECOR_TEXT = 200
|
||||
MAX_DECOR_ATTR = 64
|
||||
MAX_DECOR_UNIT = 16
|
||||
# The text block is scaled by dragging its corners; the range is what a human
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.0
|
||||
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
|
||||
# the backend must accept a plan written by a NEWER card, and a card that has
|
||||
# learnt a new symbol must not have to wait for the integration to be updated
|
||||
# before the user can save. What is enforced is the shape of the id — a flat
|
||||
# lowercase name — and its length; an id this backend has never heard of simply
|
||||
# renders as nothing in an older card.
|
||||
MAX_FURN_SYMBOL = 32
|
||||
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
vol.Match(r"^[a-z0-9_]+$"))
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
# sizes are extents — negative/zero is garbage, not "canvas slack"
|
||||
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("fill"): bool},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
# written before that keeps it, and it is read as the scale it
|
||||
# used to render at. Kept in the schema so it stays BOUNDED.
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"]),
|
||||
# size and rotation of the block (docs/LIVE-TEXT.md §block).
|
||||
# Both optional: a shape without them is the old one exactly.
|
||||
vol.Optional("scale"): vol.All(
|
||||
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# live text: the label may show one entity's value. All three
|
||||
# optional, so every existing plan validates unchanged and no
|
||||
# migration runs.
|
||||
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
|
||||
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
|
||||
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
|
||||
# and an optional rotation. It is a NEW kind, so no existing plan carries
|
||||
# it, nothing is migrated, and an integration that has this branch reads
|
||||
# every older config byte-for-byte as before.
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
|
||||
# scaled UNIFORMLY on the canvas. All three are optional and their
|
||||
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
|
||||
# and an old config validates unchanged. The offset is a normalised
|
||||
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
|
||||
# the scale is a positive multiplier in a range a human could mean.
|
||||
vol.Optional("plan_x"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_y"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
|
||||
# (midpoint + direction), thickness always in centimetres. Optional —
|
||||
# a space without `walls` validates and renders exactly as before.
|
||||
vol.Optional("walls"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
], vol.Length(max=MAX_WALLS)),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): vol.All(
|
||||
str,
|
||||
vol.Length(min=1, max=MAX_TEXT),
|
||||
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
|
||||
),
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# live robot vacuums (docs/VACUUM.md): everything optional so configs
|
||||
# from older versions stay valid untouched
|
||||
vol.Optional("vacuum"): vol.Any(
|
||||
None,
|
||||
vol.Schema({
|
||||
vol.Optional("live"): vol.Any(bool, None),
|
||||
vol.Optional("trail"): vol.Any(bool, None),
|
||||
vol.Optional("trail_mode"): vol.Any(
|
||||
None, vol.In(["never", "cleaning", "always"])
|
||||
),
|
||||
vol.Optional("room_highlight"): vol.Any(bool, None),
|
||||
vol.Optional("source"): vol.Any(str, None),
|
||||
# one 6-number affine per robot map; numbers must be finite
|
||||
vol.Optional("calibration"): vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(
|
||||
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
)
|
||||
@@ -1,799 +0,0 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
"""Runtime data of the loaded entry; answers `not_ready` when not set up.
|
||||
|
||||
The write_lock inside serializes every load→modify→save cycle of both
|
||||
stores: without it parallel WS calls lose changes (last-writer-wins)
|
||||
and the expected_rev check is not atomic.
|
||||
"""
|
||||
data = get_data(hass)
|
||||
if data is None:
|
||||
connection.send_error(msg_id, "not_ready", "House Plan is not set up")
|
||||
return data
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write? Thin wrapper over the shared policy."""
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the saved layout."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/set",
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Replace the layout entirely, with optimistic locking (audit B3).
|
||||
|
||||
Wholesale layout writes used to have no revision check at all, so two
|
||||
clients silently overwrote each other. `expected_rev` is optional for
|
||||
backwards compatibility with older cards, but when supplied it is enforced
|
||||
exactly like the config store does.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/update",
|
||||
vol.Required("device_id"): str,
|
||||
vol.Required("pos"): POS_SCHEMA,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Update the position of a single device."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/geometry/repair",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
|
||||
|
||||
For installations that hit the v1.48/v1.49 crash window: the config write
|
||||
of the migration landed, the layout write did not, and the trigger fields
|
||||
were already gone — markers and labels of that space are stranded in the
|
||||
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
|
||||
detected reliably after the fact, and re-running a transform on a layout
|
||||
that is already correct would corrupt it, so this NEVER runs by itself:
|
||||
an administrator names the space and its old aspect, may preview with
|
||||
`dry_run`, and gets a one-deep backup written in the same store write —
|
||||
`undo` restores it.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .geometry_migration import migrate_layout
|
||||
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
|
||||
return
|
||||
restored = dict(layout)
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
return
|
||||
touched = {
|
||||
k: dict(v) for k, v in layout.items()
|
||||
if isinstance(v, dict) and str(v.get("s")) == space_id
|
||||
}
|
||||
if not touched:
|
||||
# A typo'd space id used to "succeed" with moved: 0 — and its
|
||||
# empty result REPLACED the one-deep backup, destroying the very
|
||||
# undo this endpoint promises (HP-1501-02). Nothing to move means
|
||||
# nothing to save: no write, no revision bump, the backup stays.
|
||||
connection.send_error(
|
||||
msg["id"], "nothing_to_repair",
|
||||
f"No stored positions belong to space '{space_id}'",
|
||||
)
|
||||
return
|
||||
preview = {k: dict(v) for k, v in touched.items()}
|
||||
migrate_layout(preview, {space_id: msg["aspect"]})
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True, "dry_run": True, "moved": len(preview),
|
||||
"before": touched, "after": preview,
|
||||
})
|
||||
return
|
||||
new_layout = {**layout, **preview}
|
||||
new_rev = current_rev + 1
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/migrate",
|
||||
vol.Required("from_id"): str,
|
||||
vol.Required("to_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""COPY a marker's uploaded files to its new id and report the exact mapping.
|
||||
|
||||
Rebinding changes the marker id, so the files must follow (that is how the
|
||||
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
|
||||
revision-checked config save: when that save was rejected, the server kept
|
||||
the old urls while the files had already left the old folder — a permanent
|
||||
broken link (review CR-2, 2026-07-27).
|
||||
|
||||
Now it copies, never overwrites, and returns {src: dst} for every file so
|
||||
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
|
||||
folder is removed later by houseplan/files/cleanup, once the config is
|
||||
safely committed.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .const import FILES_DIR
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
src_id = sanitize_marker_id(msg["from_id"])
|
||||
dst_id = sanitize_marker_id(msg["to_id"])
|
||||
if not src_id or not dst_id or src_id == dst_id:
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR))
|
||||
src = base / src_id
|
||||
dst = base / dst_id
|
||||
|
||||
def _copy() -> dict[str, str]:
|
||||
if not src.is_dir():
|
||||
return {}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
mapping: dict[str, str] = {}
|
||||
for f in sorted(src.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it. The shared helper CLAIMS a free one atomically, so
|
||||
# a concurrent migrate or upload cannot pick the same one, and the
|
||||
# name it returns is one the content view accepts back in a request.
|
||||
name = reserve_filename(dst, f.name)
|
||||
target = dst / name
|
||||
try:
|
||||
shutil.copy2(str(f), str(target))
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True) # never leave an empty placeholder
|
||||
raise
|
||||
mapping[f.name] = name
|
||||
return mapping
|
||||
|
||||
try:
|
||||
mapping = await hass.async_add_executor_job(_copy)
|
||||
except OSError as err:
|
||||
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
plans = await hass.async_add_executor_job(_scan)
|
||||
# newest first and capped: a folder with thousands of files would otherwise
|
||||
# become one huge message, one huge list and a signing request per row
|
||||
connection.send_result(
|
||||
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
vol.Required("paths"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Sign content paths so the BROWSER can fetch them.
|
||||
|
||||
Home Assistant authenticates HTTP requests by a Bearer header or an
|
||||
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
|
||||
and a plain <a href> can send neither, so after the content endpoint became
|
||||
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
|
||||
follow-up B1 regression, 2026-07-27 — reproduced live).
|
||||
|
||||
The card asks for signatures and uses the signed urls for display.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
|
||||
out: dict[str, str] = {}
|
||||
token_id = getattr(connection, "refresh_token_id", None)
|
||||
for path in msg["paths"][:MAX_SIGN_PATHS]:
|
||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||
continue # only ever sign our own content endpoint
|
||||
clean = path.split("?", 1)[0]
|
||||
try:
|
||||
try:
|
||||
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
|
||||
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
|
||||
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
|
||||
except Exception as err: # noqa: BLE001 — signing must never break the card
|
||||
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
|
||||
continue
|
||||
out[path] = signed
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
vol.Required("marker_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/delete",
|
||||
vol.Required("device_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete the position of a single device (cleanup when a marker is removed)."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
new_rev: int | None = None
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- space configuration ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
|
||||
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
|
||||
count. Anything else belongs to the user and may point wherever they like.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
url = space.get("plan_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
|
||||
continue
|
||||
name = plan_basename(url)
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _missing_internal_plans(
|
||||
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""Newly named plan files that are not on disk.
|
||||
|
||||
Guards the pick-then-save window: another client may delete a plan between
|
||||
the moment this one chose it and the moment it saves, which would otherwise
|
||||
store a url with nothing behind it (HP-1470-02).
|
||||
|
||||
A name the stored configuration already carries is deliberately let through.
|
||||
It is already broken — repairs says so — and refusing the write would lock
|
||||
the owner out of every other edit, including the one that detaches it.
|
||||
"""
|
||||
known = _internal_plan_names(previous or {})
|
||||
return {
|
||||
name
|
||||
for name in _internal_plan_names(config)
|
||||
if name not in known and not (plans_dir / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Replace the configuration with optimistic locking (expected_rev).
|
||||
|
||||
Protects against races between several open clients: if the config has changed since
|
||||
the client's last read — a conflict error is returned, and the client must
|
||||
re-read the config and re-apply its edit on top of the fresh version.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the configuration")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
# Per-field limits bound each list; this bounds their product (HP-1454-05).
|
||||
# Everything below the caps can still add up to something no dashboard can
|
||||
# render, and the store writes it to disk on every save.
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
if "expected_rev" not in msg and current_rev:
|
||||
# audit B4: expected_rev stays optional for old cards mid-upgrade,
|
||||
# but a blind overwrite of a non-empty store is worth a warning —
|
||||
# it is exactly how a stale client silently discards someone's work.
|
||||
_LOGGER.warning(
|
||||
"House Plan: config/set without expected_rev over rev %s — "
|
||||
"the client bypasses conflict detection (outdated card?)",
|
||||
current_rev,
|
||||
)
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
# nothing about whether the file survived (HP-1470-02). External and
|
||||
# legacy urls are not ours to check and are left alone.
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
def _collect() -> None:
|
||||
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
|
||||
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(_collect)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- plan uploads ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/set",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("ext"): vol.In(sorted(PLAN_EXTENSIONS)),
|
||||
vol.Required("data"): str, # base64
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Save a space plan file; return the URL for the card."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may upload plans")
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(msg["data"], validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
connection.send_error(msg["id"], "invalid_data", "data must be valid base64")
|
||||
return
|
||||
if len(raw) > MAX_PLAN_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||
return
|
||||
|
||||
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
# files of a differently named space.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _check_and_write() -> None:
|
||||
# one executor job for the pair, under upload_lock: the measurement
|
||||
# is only a bound if nothing else writes between it and our write
|
||||
# (HP-1490-02). A failed write reserves nothing — the file either
|
||||
# exists and is counted by the next scan, or does not and is not.
|
||||
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(raw)
|
||||
|
||||
data = _runtime(hass, connection, msg["id"])
|
||||
if data is None:
|
||||
return
|
||||
async with data.upload_lock:
|
||||
try:
|
||||
await hass.async_add_executor_job(_check_and_write)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
|
||||
"""Markers changed — the trail recorder must re-resolve what it watches."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if rec:
|
||||
hass.async_create_task(rec.async_refresh())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
@@ -1,17 +0,0 @@
|
||||
# Synthetic demo home
|
||||
|
||||
A fully fictional house (plans, devices, states) used for README screenshots,
|
||||
the demo GIF and headless smoke tests — so no real home data ever appears in
|
||||
public materials.
|
||||
|
||||
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
|
||||
fake `hass` (registries, states, `callWS`, `callService`, floors).
|
||||
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
|
||||
needs the repo's devDependencies). The card bundle is copied from `dist/`:
|
||||
`cp dist/houseplan-card.js demo/srv/assets/`.
|
||||
- `serve.mjs` — playwright launcher (route interception, no web server).
|
||||
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
|
||||
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
|
||||
|
||||
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
|
||||
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
|
||||
@@ -1,43 +0,0 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
// glow на текущем пространстве
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.mode = c._mode;
|
||||
out.suppress = c._suppressClick;
|
||||
out.holdFired = c._holdFired;
|
||||
out.drag = !!c._drag;
|
||||
// реальный клик по включённой лампе (элемент .dev)
|
||||
const litDev = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
out.hasLit = !!litDev;
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.textContent.includes(litDev.name) || true);
|
||||
// найдём элемент точно: по индексу устройства
|
||||
const devEls = [...sr().querySelectorAll('.dev')];
|
||||
out.devCount = devEls.length;
|
||||
const target = devEls[c._devices.filter((d) => d.space === c._space).indexOf(litDev)] || devEls[0];
|
||||
const before = calls.length;
|
||||
c._infoCard = null;
|
||||
// эмулируем полный цикл: pointerdown/up + click
|
||||
const opts = { bubbles: true, composed: true };
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 5 }));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 5 }));
|
||||
target.dispatchEvent(new MouseEvent('click', opts));
|
||||
await c.updateComplete;
|
||||
out.reaction = calls.length > before ? 'service:' + JSON.stringify(calls.at(-1)) : c._infoCard ? 'info' : 'NOTHING';
|
||||
out.suppressAfter = c._suppressClick;
|
||||
// и через прямой вызов
|
||||
c._infoCard = null;
|
||||
c._clickDevice(new MouseEvent('click'), litDev);
|
||||
await c.updateComplete;
|
||||
out.directReaction = calls.length > before + 1 ? 'service' : c._infoCard ? 'info' : 'NOTHING';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -1,22 +0,0 @@
|
||||
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
|
||||
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import * as mdi from '@mdi/js';
|
||||
|
||||
const names = new Set();
|
||||
const scan = (dir) => {
|
||||
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
|
||||
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
|
||||
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
|
||||
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
|
||||
}
|
||||
};
|
||||
scan('src'); scan('demo');
|
||||
const map = {};
|
||||
for (const n of [...names].sort()) {
|
||||
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
|
||||
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
|
||||
}
|
||||
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
|
||||
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
|
||||
@@ -1,25 +0,0 @@
|
||||
// Репро №3: пользовательский путь — открыть диалог, выставить «комфорт от 25», сохранить
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// living room: единственный термометр 22.4 → подменим на 24.0 как у пользователя
|
||||
const h = window.__mkHass();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '24.0' } };
|
||||
c.hass = h; c._regSignature=''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// путь пользователя: диалог → режим temp, min=25 (макс не трогаем) → сохранить
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.dialogInit = { min: c._spaceDialog.tempMin, max: c._spaceDialog.tempMax, fill: c._spaceDialog.fillMode };
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'temp', tempMin: 25 };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
|
||||
out.savedSettings = sp.settings;
|
||||
const styles = [...sr().querySelectorAll('.room.styled')].map(r => (r.getAttribute('style')||'').match(/--room-fill:([^;]+)/)?.[1]);
|
||||
out.livingFill = styles[0];
|
||||
out.expected = '#4fc3f7 (blue: 24 < 25)';
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -1,67 +0,0 @@
|
||||
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
|
||||
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
// ---- assertion harness (audit T1) --------------------------------------
|
||||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||
// build reported success. `check()` accumulates named failures, `finish()`
|
||||
// prints them and sets the exit code.
|
||||
const _failures = [];
|
||||
let _pageErrors = 0;
|
||||
|
||||
/** Assert one named fact. `expected` defaults to true. */
|
||||
export function check(name, actual, expected = true) {
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Assert a whole result object: every key must equal true unless listed. */
|
||||
export function checkAll(out, expected = {}) {
|
||||
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Print the result, report failures, close the browser, set the exit code. */
|
||||
export async function finish(browser, out) {
|
||||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||
await browser?.close?.();
|
||||
if (_failures.length) {
|
||||
console.error('\nFAILED (' + _failures.length + '):');
|
||||
for (const f of _failures) console.error(' - ' + f);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('OK');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
if (p === '/') p = '/demo.html';
|
||||
const f = ROOT + p;
|
||||
existsSync(f)
|
||||
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
|
||||
: r.fulfill({ status: 404, body: 'nf' });
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||||
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
return { page, browser };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
|
||||
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
|
||||
// finger-sized corner handles, and the live "W × H" badge
|
||||
// stating the picture's real size through cell_cm;
|
||||
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
|
||||
// the ROOM CONTOURS, so the scene colour is visible around
|
||||
// them and under the parts of the picture nobody has drawn
|
||||
// over. The picture is scaled down here so both are obvious.
|
||||
// Usage: node demo/shot_backdrop.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const settle = () => page.waitForTimeout(260);
|
||||
|
||||
// ---- 1) the frame, mid-drag ---------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('decor');
|
||||
c._decorTool = 'backdrop';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
{
|
||||
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
|
||||
// the frame AND the live badge at once
|
||||
const [ax, ay] = await screenPt(1000, 900);
|
||||
const [bx, by] = await screenPt(820, 760);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
|
||||
await page.mouse.up();
|
||||
}
|
||||
await settle();
|
||||
|
||||
// ---- 2) the paper is the rooms ------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// a picture pulled well inside the plan and an acid scene colour: what shows
|
||||
// through around the rooms is the SCENE, which is the whole new rule
|
||||
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
|
||||
c._cfgEpoch++; c._frame = null;
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._fitAll();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
|
||||
@@ -1,30 +0,0 @@
|
||||
// Capture: a curtain on the move — the breathing ring (.covermove) around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -1,69 +0,0 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -1,36 +0,0 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
@@ -1,63 +0,0 @@
|
||||
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
|
||||
import { launch } from './serve.mjs';
|
||||
const OUT = process.env.HP_SHOT_DIR || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelectorAll('.modetab')[2].click();
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'furniture';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr.querySelector('.furnitem[data-symbol="sofa"]').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/furniture_palette.png` });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const P = 1000 / 240;
|
||||
const cm = (v) => (v / 5) * P / 1000; // cm -> normalised
|
||||
const put = (id, symbol, w, h, cx, cy, angle) => ({
|
||||
id, kind: 'furniture', symbol,
|
||||
x: cx / 1000 - cm(w) / 2, y: cy / 1000 - cm(h) / 2,
|
||||
w: cm(w), h: cm(h), color: '#8d6e63', width: 3,
|
||||
...(angle ? { angle } : {}),
|
||||
});
|
||||
// r1 living 40..550 x 140..580 | r2 kitchen 550..960 x 140..460
|
||||
// r3 bedroom 550..960 x 460..860 | r4 hall 40..550 x 580..860
|
||||
c._curSpaceCfg.decor = [
|
||||
put('f1', 'sofa', 220, 90, 250, 140 + 37.5),
|
||||
put('f2', 'coffee_table', 110, 60, 250, 300),
|
||||
put('f3', 'tv', 120, 30, 250, 570),
|
||||
put('f4', 'armchair', 90, 85, 90, 340, 90),
|
||||
put('f5', 'table_dining', 140, 80, 700, 300),
|
||||
put('f6', 'chair', 45, 45, 700, 240),
|
||||
put('f7', 'chair', 45, 45, 700, 360, 180),
|
||||
put('f8', 'fridge', 60, 65, 590, 140 + 27, 0),
|
||||
put('f9', 'stove', 60, 60, 670, 140 + 25),
|
||||
put('f10', 'kitchen_sink', 80, 60, 760, 140 + 25),
|
||||
put('f11', 'dishwasher', 60, 60, 840, 140 + 25),
|
||||
put('f12', 'bed_double', 160, 200, 700, 460 + 42),
|
||||
put('f13', 'nightstand', 45, 40, 620, 470),
|
||||
put('f14', 'nightstand', 45, 40, 780, 470),
|
||||
put('f15', 'wardrobe', 100, 60, 910, 700, 90),
|
||||
put('f16', 'washer', 60, 60, 120, 860 - 25, 180),
|
||||
put('f17', 'toilet', 40, 70, 220, 860 - 29, 180),
|
||||
put('f18', 'bathtub', 170, 75, 400, 860 - 31, 180),
|
||||
put('f19', 'plant', 40, 40, 500, 620),
|
||||
put('f20', 'shower', 90, 90, 490, 700),
|
||||
];
|
||||
c._decorTool = 'select';
|
||||
c._decorSel = 'f1';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/furniture_plan.png` });
|
||||
await browser.close();
|
||||
console.log('shots written to', OUT);
|
||||
@@ -1,31 +0,0 @@
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
// the flash is keyed to a WITNESSED off→on transition (owner's rule
|
||||
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
|
||||
const set = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await set('off');
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -1,27 +0,0 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -1,45 +0,0 @@
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
|
||||
// sharp corner shoots a spike far past the walls that meet there; past the
|
||||
// miter limit it flips to a flat bevel. Room borders now join round, like the
|
||||
// decor lines already did. Two rooms make the defect obvious: an L (a concave
|
||||
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
|
||||
// where the miter spike is longest before the miter limit clips it to a bevel.
|
||||
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const name = process.argv[3] || 'room_joins';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
delete sp.plan_url;
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: '', area: 'living_room',
|
||||
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
|
||||
{ id: 'rW', name: '', area: 'kitchen',
|
||||
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
|
||||
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
|
||||
// a 5-unit spike, and the spike is the entire point of the shot
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const svg = c.shadowRoot.querySelector('.stage svg');
|
||||
const poly = [...svg.querySelectorAll('polygon.room')].pop();
|
||||
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
|
||||
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
|
||||
const m = svg.getScreenCTM();
|
||||
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
|
||||
});
|
||||
await page.screenshot({ path: `${outDir}/${name}.png`,
|
||||
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
|
||||
await browser.close();
|
||||
console.log('shot written to ' + outDir + '/' + name + '.png');
|
||||
@@ -1,52 +0,0 @@
|
||||
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
|
||||
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// instant colors for the still shot: the day/night transition is tens of seconds
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
|
||||
};
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
// a warm evening: the sun low in the west; the living-room light off so
|
||||
// the glow pool does not swamp the west wedge
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
|
||||
},
|
||||
} };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
|
||||
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,46 +0,0 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,64 +0,0 @@
|
||||
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
|
||||
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
|
||||
// the value can be picked by looking rather than by arguing. The alphas other
|
||||
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
|
||||
// DOM, which is why a rebuild is not needed to compare them.
|
||||
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
|
||||
// `0` is the "before" frame: the wedge alone, with no rim at all.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
|
||||
|
||||
const setup = async (scene) => {
|
||||
await page.evaluate(async (sc) => {
|
||||
const c = window.__card;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain paper, nothing to hide behind
|
||||
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
|
||||
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 250, elevation: 35 } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, scene);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
|
||||
// scale relative to the current values would collapse to zero the moment the
|
||||
// 0 frame is taken and never come back.
|
||||
const setAlpha = async (peak) => {
|
||||
await page.evaluate((a) => {
|
||||
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
|
||||
const stops = [...g.querySelectorAll('stop')];
|
||||
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
|
||||
const p0 = g.__base[0] || 1;
|
||||
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
|
||||
}
|
||||
}, peak);
|
||||
await page.waitForTimeout(120);
|
||||
};
|
||||
|
||||
for (const scene of ['light', 'dark']) {
|
||||
await setup(scene);
|
||||
for (const a of ALPHAS) {
|
||||
await setAlpha(a);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,48 +0,0 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,56 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const g = c._gridPitch;
|
||||
const guides = () => sr().querySelectorAll('.alignline').length;
|
||||
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const v = poly[0];
|
||||
c._path = [[v[0] + g * 10, v[1] + g * 10]];
|
||||
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.drawGuide = guides() >= 1;
|
||||
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
|
||||
// нет выравнивания → нет гидов
|
||||
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGuideOffAxis = guides() === 0;
|
||||
// 2) бейдж угла: 45° красит
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml2 = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
|
||||
c._path = []; c._cursorPt = null;
|
||||
// 3) редактор устройств: drag значка в линию с другим значком
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space);
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
c._drag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
|
||||
// 5) в Просмотре гидов не бывает
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noneInView = guides() === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,88 +0,0 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -1,373 +0,0 @@
|
||||
/**
|
||||
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
|
||||
*
|
||||
* The whole contract, on the demo's f1 — which IS an image plan, so every
|
||||
* assertion here lands on exactly the case that used to behave differently:
|
||||
* • the transform frame exists in the backdrop editor and nowhere else;
|
||||
* • dragging the body moves the picture and writes plan_x/plan_y;
|
||||
* • a corner handle scales UNIFORMLY about the opposite corner;
|
||||
* • live badges state the real size in metres, through cell_cm;
|
||||
* • position and size land on the grid, Shift steps off it;
|
||||
* • the paper follows the ROOMS even here, and the picture is drawn above
|
||||
* the paper and below the walls;
|
||||
* • «Вписать всё» still finds a picture that has been dragged away;
|
||||
* • the static card reads the same three fields.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._bdDrag = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = (m) => page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, m);
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
|
||||
const spaceCfg = () => page.evaluate(() => {
|
||||
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
|
||||
});
|
||||
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
|
||||
const imageRect = () => page.evaluate(() => {
|
||||
const im = window.__card.renderRoot.querySelector('.stage svg image');
|
||||
if (!im) return null;
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const frameRect = () => page.evaluate(() => {
|
||||
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
|
||||
if (!b) return null;
|
||||
const a = (n) => Number(b.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
|
||||
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
|
||||
|
||||
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
|
||||
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
|
||||
const PITCH = 1000 / 240;
|
||||
// "on a node", allowing for the 6 decimals the normalised config keeps
|
||||
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
|
||||
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
|
||||
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
|
||||
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
|
||||
|
||||
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
|
||||
await mode('view');
|
||||
await settle();
|
||||
check('no_frame_in_view', await q('.bdframe'), 0);
|
||||
await mode('plan');
|
||||
await settle();
|
||||
check('no_frame_in_plan', await q('.bdframe'), 0);
|
||||
const tool = (t) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, t);
|
||||
|
||||
await mode('decor');
|
||||
await settle();
|
||||
// the frame is on screen the moment the backdrop editor opens…
|
||||
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
|
||||
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
|
||||
check('frame_hugs_the_picture', await frameRect(), BASE);
|
||||
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
|
||||
// …and the picture has a tool of its own, offered because f1 HAS a picture
|
||||
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
|
||||
// …which is ARMED the moment the editor opens (owner 2026-08-05: «не
|
||||
// получается двигать картинку-подложку в режиме редактора подложки» — the
|
||||
// frame promised a draggable picture while the promise needed a tool nobody
|
||||
// had found). The editor is named after the picture; it opens on it.
|
||||
check('picture_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'backdrop');
|
||||
check('grab_cursor', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
|
||||
// select still exists and still leaves the body to the pan (smoke_pan_any_zoom)
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('no_grab_cursor_under_select', await page.evaluate(() =>
|
||||
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
|
||||
// a drawing tool owns the drag instead — no frame competing with it
|
||||
await tool('rect');
|
||||
await settle();
|
||||
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
|
||||
// ---------- 2) drag the body: the picture moves, the plan does not --------
|
||||
const roomsBefore = await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
// +102 / +52 render units: neither is a whole number of grid steps, so the
|
||||
// snap has something to do (24 steps = 100, 12 steps = 50)
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
const live = await badges();
|
||||
check('live_badge_while_dragging', live.length, 1);
|
||||
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
|
||||
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
|
||||
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
|
||||
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
}
|
||||
check('badge_gone_after_release', (await badges()).length, 0);
|
||||
{
|
||||
const cfg = await spaceCfg();
|
||||
const im = await imageRect();
|
||||
// the gesture asked for +102/+52 render units; the pointer arrives in whole
|
||||
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
|
||||
// corner lands on a lattice node within one step of where the finger let go
|
||||
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
|
||||
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
|
||||
check('scale_untouched_by_a_move', cfg.k, 1);
|
||||
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
|
||||
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
|
||||
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
|
||||
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
|
||||
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
|
||||
// or the picture would run away from the finger.
|
||||
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
|
||||
check('frame_follows_the_picture', await frameRect(), im);
|
||||
}
|
||||
check('the_plan_geometry_never_moved', await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
|
||||
|
||||
// ---------- 3) Shift steps off the grid ----------------------------------
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
// Shift is not "a different snap", it is NO snap: the corner is wherever the
|
||||
// finger left it, to the pixel — and a pixel is a fraction of a grid step,
|
||||
// so at least one axis is bound to sit between the nodes.
|
||||
check('shift_lands_where_the_finger_did',
|
||||
Math.abs(im.x - 102) <= 1 && Math.abs(im.y - 152) <= 1, true);
|
||||
check('shift_is_off_the_lattice', onGrid(im.x) && onGrid(im.y), false);
|
||||
}
|
||||
|
||||
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
// the bottom-right corner of the default rect is (1000, 900); pull it in to
|
||||
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
|
||||
// stay 1.25, whatever the vertical component of the gesture says
|
||||
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
|
||||
const [bx, by] = await screenPt(700, 600);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await settle();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
check('live_badge_while_scaling', (await badges()).length, 1);
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
|
||||
check('scaled_down', im.w < BASE.w, true);
|
||||
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
|
||||
const cfg = await spaceCfg();
|
||||
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
|
||||
check('scaled_width_is_on_the_grid',
|
||||
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
|
||||
// …and the badge states the picture's REAL size through cell_cm
|
||||
check('badge_matches_the_new_size', await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
|
||||
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
|
||||
c.requestUpdate();
|
||||
return c.updateComplete.then(() =>
|
||||
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
|
||||
}), `${metres(im.w)} × ${metres(im.h)}`);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
|
||||
}
|
||||
|
||||
// ---------- 5) the reset button ------------------------------------------
|
||||
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
|
||||
await settle();
|
||||
check('reset_puts_the_picture_back', await imageRect(), BASE);
|
||||
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
|
||||
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
|
||||
|
||||
// ---------- 5b) the SELECT tool still pans right over the picture ---------
|
||||
// The body of the picture is most of the screen; claiming it outside its own
|
||||
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
|
||||
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const before = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
const after = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
|
||||
}), true);
|
||||
|
||||
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
|
||||
await mode('view');
|
||||
await settle();
|
||||
const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
paperShapes: papers.length,
|
||||
rooms: m.rooms.length,
|
||||
// not one paper shape the size of the picture any more
|
||||
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
|
||||
};
|
||||
});
|
||||
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
|
||||
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
|
||||
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
|
||||
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
|
||||
|
||||
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
|
||||
const editorWhite = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const probe = async (mode) => {
|
||||
c._setMode(mode);
|
||||
await upd();
|
||||
const stage = sr().querySelector('.stage');
|
||||
const paper = sr().querySelector('.stage svg .hp-paper');
|
||||
return {
|
||||
mode,
|
||||
stageWhite: getComputedStyle(stage).backgroundColor === 'rgb(255, 255, 255)',
|
||||
paperWhite: !paper || getComputedStyle(paper).fill === 'rgb(255, 255, 255)',
|
||||
hasBg: !!c._spaceModel().bg,
|
||||
noNoplan: !stage.classList.contains('noplan'),
|
||||
};
|
||||
};
|
||||
const plan = await probe('plan');
|
||||
const devices = await probe('devices');
|
||||
const decor = await probe('decor');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const viewStage = sr().querySelector('.stage');
|
||||
// View with a backdrop must NOT force white — theme/card colour stays
|
||||
const viewForcedWhite = getComputedStyle(viewStage).backgroundColor === 'rgb(255, 255, 255)'
|
||||
&& !viewStage.classList.contains('noplan');
|
||||
return { plan, devices, decor, viewForcedWhite, hasBg: plan.hasBg };
|
||||
});
|
||||
check('editors_have_backdrop', editorWhite.hasBg, true);
|
||||
check('plan_editor_stage_white_with_backdrop', editorWhite.plan.stageWhite && editorWhite.plan.noNoplan, true);
|
||||
check('plan_editor_paper_white_with_backdrop', editorWhite.plan.paperWhite, true);
|
||||
check('devices_editor_stage_white_with_backdrop', editorWhite.devices.stageWhite, true);
|
||||
check('decor_editor_stage_white_with_backdrop', editorWhite.decor.stageWhite, true);
|
||||
check('view_with_backdrop_is_not_forced_white', editorWhite.viewForcedWhite, false);
|
||||
|
||||
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
|
||||
c._cfgEpoch++; c._frame = null; c.requestUpdate();
|
||||
return c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._fitAll();
|
||||
const b = c._baseVb();
|
||||
const bg = c._spaceModel().bg;
|
||||
return { b, bg };
|
||||
});
|
||||
check('the_frame_reaches_the_moved_picture',
|
||||
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
|
||||
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
|
||||
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
|
||||
check('and_the_moved_picture_is_where_the_transform_says',
|
||||
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
|
||||
|
||||
// ---------- 8) the static card reads the same three fields ---------------
|
||||
const stat = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const root = card.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const im = root.querySelector('image');
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
const kids = [...root.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
return {
|
||||
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
|
||||
papers: root.querySelectorAll('.hp-paper').length,
|
||||
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'),
|
||||
};
|
||||
});
|
||||
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
|
||||
check('static_card_papers_the_rooms', stat.papers, 4);
|
||||
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
|
||||
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
|
||||
|
||||
await restore();
|
||||
await finish(browser);
|
||||
@@ -1,355 +0,0 @@
|
||||
// New setting "background color around the plan": global (config.settings.bg_color,
|
||||
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
|
||||
// to the stage in view and kiosk modes and to the static space-card. Editors keep
|
||||
// their own canvas; the open dialogs preview the pending color live.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stageBg = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
|
||||
};
|
||||
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
||||
|
||||
const themeBg = await stageBg(); // theme default from the stylesheet
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
// 1) global setting colors the stage
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
|
||||
out.globalApplies = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 2) per-space override wins
|
||||
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
|
||||
out.spaceOverrides = (await stageBg()) === rgb('#663311');
|
||||
|
||||
// 3) clearing the space setting inherits the global again
|
||||
delete sp.settings.bg_color;
|
||||
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 4) clearing the global returns the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
out.clearRestoresTheme = (await stageBg()) === themeBg;
|
||||
|
||||
// 5) invalid stored value is ignored (renderer never trusts garbage)
|
||||
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
|
||||
out.garbageIgnored = (await stageBg()) === themeBg;
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
|
||||
// 6) the settings dialog previews its pending color live + saves it
|
||||
let saved = null;
|
||||
const origWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => {
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
return origWS.call(c.hass, m);
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
|
||||
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 7) the space dialog previews and saves the override
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
|
||||
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
|
||||
saved = null;
|
||||
await c._saveSpaceDialog();
|
||||
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
|
||||
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
|
||||
|
||||
// ...and clearing it in the dialog inherits the global again
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
|
||||
await c._saveSpaceDialog();
|
||||
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
|
||||
const wire = JSON.parse(JSON.stringify(saved));
|
||||
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
|
||||
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
|
||||
c.hass.callWS = origWS;
|
||||
|
||||
// 8) editors keep their own canvas (inline bg only in view/kiosk)
|
||||
c._setMode('decor');
|
||||
const editorBg = await stageBg();
|
||||
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
|
||||
c._setMode('view');
|
||||
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c.hass;
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
|
||||
document.body.appendChild(k);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
} };
|
||||
const scHost = document.createElement('div');
|
||||
document.body.appendChild(scHost);
|
||||
const sc = document.createElement('houseplan-space-card');
|
||||
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
sc.hass = hass2;
|
||||
scHost.appendChild(sc);
|
||||
const t0 = Date.now();
|
||||
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
sc._cfg = null; // force a config re-read
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
sc.requestUpdate(); await sc.updateComplete;
|
||||
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
|
||||
scHost.remove();
|
||||
|
||||
// tidy: drop the color so later smokes on this page see the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
if (spx?.settings) delete spx.settings.bg_color;
|
||||
await stageBg();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
|
||||
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
|
||||
const p = shapes()[0];
|
||||
out.paperExists = !!p;
|
||||
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
|
||||
// …and NOT the image: no paper rect the size of the backdrop any more
|
||||
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
|
||||
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
|
||||
if (p) {
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
c._cfgRev++;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, paper);
|
||||
|
||||
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
|
||||
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
|
||||
// plus a detached building must NOT grow a white bounding rectangle. One
|
||||
// paper shape per room, in exactly the room's own geometry; the acid scene
|
||||
// colour shows in the L's pocket and between the buildings, right up to the
|
||||
// exterior walls; inside the rooms it never shows. A live resize preview
|
||||
// (_rszPreview) moves the paper together with the dragged wall.
|
||||
const drawn = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
sp.plan_url = ''; // hand-drawn plan now
|
||||
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
|
||||
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
|
||||
];
|
||||
sp.openings = [];
|
||||
sp.decor = [];
|
||||
c._devices = []; // no icons over the pixel probes below
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
|
||||
let ps = papers();
|
||||
out.paperPerRoom = ps.length === sp.rooms.length;
|
||||
out.paperShapesMatchRooms = ps.length === 2
|
||||
&& ps[0].tagName.toLowerCase() === 'polygon'
|
||||
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
|
||||
&& ps[1].tagName.toLowerCase() === 'rect';
|
||||
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
|
||||
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
|
||||
const cs = getComputedStyle(p);
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
|
||||
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
|
||||
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
|
||||
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
|
||||
// must move WITH the room, not lag behind until the drop
|
||||
const pv = JSON.parse(JSON.stringify(sp));
|
||||
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
|
||||
c._rszPreview = { space: c._space, sp: pv };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
|
||||
c._rszPreview = null;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
|
||||
// screen coords for the pixel probes (svg user units -> viewport px)
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
|
||||
out.probes = {
|
||||
pocket: toScr(500, 500), // the L's notch — bg must show here
|
||||
gap: toScr(687, 300), // between the two buildings
|
||||
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
|
||||
};
|
||||
return out;
|
||||
});
|
||||
const { probes } = drawn;
|
||||
delete drawn.probes;
|
||||
Object.assign(res, drawn);
|
||||
|
||||
// pixel proof: the acid scene colour IS in the L's pocket and between the
|
||||
// buildings (where the old bounding-box paper drew a white square), and is
|
||||
// NOT inside any room
|
||||
if (probes) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probes }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width;
|
||||
cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
|
||||
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
|
||||
return {
|
||||
acidInPocket: acid(at(probes.pocket)),
|
||||
acidBetweenBuildings: acid(at(probes.gap)),
|
||||
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
|
||||
};
|
||||
}, { shot, probes });
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.acidInPocket = false;
|
||||
res.acidBetweenBuildings = false;
|
||||
res.noAcidInsideRooms = false;
|
||||
}
|
||||
|
||||
// ---- 13) the static space-card follows the same paper contract ----------
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
// NOTE: the config-store cache is module-level and already primed by the
|
||||
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
|
||||
// callWS would never reach the card — inject the snapshot directly.
|
||||
const mk = async (cfg) => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
el._snap = { config: cfg, rev: 1, layout: {} };
|
||||
el._loadedOnce = true;
|
||||
el.hass = c.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await el.updateComplete;
|
||||
return { el, host };
|
||||
};
|
||||
const base = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
|
||||
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
|
||||
const a = await mk(base);
|
||||
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticPaperExists = !!pa;
|
||||
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
|
||||
&& getComputedStyle(pa).fill !== 'none';
|
||||
a.host.remove();
|
||||
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
|
||||
const drawnCfg = JSON.parse(JSON.stringify(base));
|
||||
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
|
||||
dsp.plan_url = '';
|
||||
const b = await mk(drawnCfg);
|
||||
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
|
||||
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
|
||||
out.staticDrawnPaperShapes = pbs.length === 2
|
||||
&& pbs[0].tagName.toLowerCase() === 'polygon'
|
||||
&& pbs[1].tagName.toLowerCase() === 'rect';
|
||||
b.host.remove();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, stat);
|
||||
checkAll({ ...paper, ...drawn, ...stat,
|
||||
acidInPocket: res.acidInPocket,
|
||||
acidBetweenBuildings: res.acidBetweenBuildings,
|
||||
noAcidInsideRooms: res.noAcidInsideRooms });
|
||||
await finish(browser, res);
|
||||
@@ -1,50 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
|
||||
out.twoRadios = radios().length === 2;
|
||||
out.virtDefault = radios()[0].checked && !radios()[1].checked;
|
||||
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
|
||||
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
|
||||
radios()[1].click(); await c.updateComplete;
|
||||
out.dropShown = !!sr().querySelector('.dropbtn');
|
||||
out.panelOpen = !!sr().querySelector('.droppanel');
|
||||
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
|
||||
out.saveDisabled = saveBtn?.disabled === true;
|
||||
// без чекбокса в списке нет individual-сущностей устройств
|
||||
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
|
||||
const entLabel = c._t('marker.sub_entity');
|
||||
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
|
||||
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
|
||||
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
|
||||
// включить чекбокс → сущности появились
|
||||
const cb = sr().querySelector('.entcheck input');
|
||||
cb.click(); await c.updateComplete;
|
||||
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
|
||||
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
|
||||
// выбрать первый кандидат → панель закрылась, сейв разблокирован
|
||||
sr().querySelector('.cand').click(); await c.updateComplete;
|
||||
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
|
||||
out.panelClosed = !sr().querySelector('.droppanel');
|
||||
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
|
||||
// радио назад на «Виртуальное» → binding=virtual
|
||||
radios()[0].click(); await c.updateComplete;
|
||||
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
|
||||
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.editHaMode = c._markerDialog.bindingMode === 'ha';
|
||||
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
|
||||
out.editPanelClosed = !sr().querySelector('.droppanel');
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,212 +0,0 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// frame — hiding a marker that had wandered into the yard left the visible
|
||||
// house a dot in the corner (the full card AND the static
|
||||
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
||||
//
|
||||
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
||||
// mid-drag would move the ground under the pointer); that union was memoised
|
||||
// without the mode, so it survived the way back into View.
|
||||
//
|
||||
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
||||
// measured ALL rooms — so the stray it had just rejected still blew every
|
||||
// marker of the main plan up ~91x.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------- 02: editor -> view
|
||||
const oneRoomAt = (x0) => ({
|
||||
spaces: [{
|
||||
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
});
|
||||
|
||||
await load(oneRoomAt(0.1), {}, 'e1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const round = () => c._baseVb().map((n) => Math.round(n));
|
||||
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
||||
// move the only room 5 canvases to the right, inside the Plan editor
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
||||
c._serverCfg = cfg;
|
||||
c._modelCache = null;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const inEditor = round();
|
||||
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
||||
// pan and defines zoom 1, and it must not shrink under a live gesture
|
||||
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const back = round();
|
||||
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
||||
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
||||
// and back into an editor it starts from the CURRENT geometry, not the old union
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
||||
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 03: icons and outliers
|
||||
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
||||
// the stray (that part always worked); the ICON UNIT used to measure all four
|
||||
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
||||
// big and covered the house.
|
||||
const withFarRoom = (far) => ({
|
||||
spaces: [{
|
||||
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
||||
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
||||
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
||||
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
||||
],
|
||||
}],
|
||||
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
||||
|
||||
const iconPx = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
||||
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
||||
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
||||
});
|
||||
|
||||
await load(withFarRoom(false), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const plain = await iconPx();
|
||||
await load(withFarRoom(true), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const strayed = await iconPx();
|
||||
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
||||
out.strayRoomDoesNotInflateTheIcons =
|
||||
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
||||
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с управляемой сущностью
|
||||
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('.dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
out.rows = rows.length > 0;
|
||||
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
|
||||
const btn = sr().querySelector('.entbtn');
|
||||
out.hasButton = !!btn;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
|
||||
// 3) кнопка реально переключает
|
||||
const before = calls.length;
|
||||
btn.click(); await c.updateComplete;
|
||||
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
|
||||
// 4) замок никогда не переключается из карточки
|
||||
const n = calls.length;
|
||||
c._cardToggle('lock.front_door');
|
||||
out.lockNeverToggles = calls.length === n;
|
||||
// 5) диагностические/конфиг-сущности не засоряют список
|
||||
const ents = c._cardEntities(dev).map((e) => e.eid);
|
||||
out.noConfigEntities = ents.every((e) => {
|
||||
const cat = c.hass.entities[e]?.entity_category;
|
||||
return cat !== 'config' && cat !== 'diagnostic';
|
||||
});
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,51 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const stage = sr().querySelector('.stage');
|
||||
const label = sr().querySelector('.roomlabel');
|
||||
out.hasLabel = !!label;
|
||||
// 1) draw: клик по карточке не добавляет точку
|
||||
c._tool = 'draw'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.drawIgnored = c._path.length === 0;
|
||||
// 2) клик по угловой метке тоже не считается кликом инструмента
|
||||
const handle = label.querySelector('.rlhandle');
|
||||
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.handleIgnored = c._path.length === 0;
|
||||
// 3) во время резайза клики по сцене игнорируются
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
|
||||
const before = c._path.length;
|
||||
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
|
||||
out.duringResizeIgnored = c._path.length === before;
|
||||
c._rlResize = null;
|
||||
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
|
||||
const r = stage.getBoundingClientRect();
|
||||
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
|
||||
let fx = 0.4, fy = 0.4;
|
||||
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
|
||||
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
|
||||
const raw = c._svgPoint(ev);
|
||||
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
|
||||
}
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,76 +0,0 @@
|
||||
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||
let scans = 0;
|
||||
const wrap = (h) => {
|
||||
const ents = h.entities;
|
||||
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
return { ...h, entities: traced };
|
||||
};
|
||||
const fresh = () => wrap(window.__mkHass());
|
||||
|
||||
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
const fewRooms = scans;
|
||||
|
||||
// повторные рендеры на том же снимке hass реестр не трогают
|
||||
scans = 0;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.scansOnRerender = scans;
|
||||
|
||||
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
const extra = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||
}
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||
out.scansSameWith44Rooms = scans === fewRooms;
|
||||
out.scansPerUpdate = scans;
|
||||
|
||||
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||
const h = fresh();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||
c.hass = h; await c.updateComplete;
|
||||
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||
out.climateIsMap = c._climate() instanceof Map;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
|
||||
// не само число, а что оно не растёт вместе с числом комнат.
|
||||
checkAll(res, {
|
||||
scansOnRerender: 0,
|
||||
roomCount: 44,
|
||||
scansSameWith44Rooms: true,
|
||||
scansPerUpdate: 2,
|
||||
tempBefore: 22.4,
|
||||
tempAfter: 33.3,
|
||||
climateIsMap: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -1,147 +0,0 @@
|
||||
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
|
||||
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
|
||||
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
|
||||
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
|
||||
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
|
||||
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const h = window.__mkHass();
|
||||
const inject = (hass) => ({
|
||||
...hass,
|
||||
devices: {
|
||||
...hass.devices,
|
||||
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
|
||||
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
|
||||
},
|
||||
entities: {
|
||||
...hass.entities,
|
||||
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
|
||||
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
...hass.states,
|
||||
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
|
||||
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
|
||||
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
|
||||
// мёртвый климат: state unavailable, атрибутов нет
|
||||
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
|
||||
},
|
||||
});
|
||||
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
|
||||
window.__injectClimate();
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
});
|
||||
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
|
||||
|
||||
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.acBuilt = !!ac;
|
||||
o.noBadgeByDefault = c._liveTemp(ac) === null;
|
||||
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
|
||||
return o;
|
||||
}), { avgWithoutOption: 20 });
|
||||
|
||||
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// у обычного термометра чекбокса нет
|
||||
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
|
||||
c._openMarkerDialog(th); await c.updateComplete;
|
||||
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// у кондиционера — есть и по умолчанию снят
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
const row = sr().querySelector('.dialog .climrow input');
|
||||
o.checkboxForClimate = !!row;
|
||||
o.uncheckedByDefault = !!row && !row.checked;
|
||||
if (row) row.scrollIntoView({ block: 'center' });
|
||||
return o;
|
||||
}));
|
||||
|
||||
// реальный клик по чекбоксу — координатами, через весь стек событий
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
|
||||
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
|
||||
});
|
||||
check('checkboxClickable', !!box);
|
||||
if (box) await page.mouse.click(box.x, box.y);
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
|
||||
o.savedInMarker = m?.use_climate_temp === true;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
|
||||
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
|
||||
o.badgeVisible = badges.includes('23.5°');
|
||||
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
||||
o.avgWithOption = c._climate().get('living_room')?.temp;
|
||||
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
|
||||
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
|
||||
return o;
|
||||
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
|
||||
|
||||
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = [...c._serverCfg.markers,
|
||||
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
|
||||
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
|
||||
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
|
||||
// а гостиная как была 21.8
|
||||
o.avgStillCorrect = c._climate().get('living_room')?.temp;
|
||||
return o;
|
||||
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
|
||||
// --- чекбокс переживает пересоздание диалога ------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
|
||||
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
|
||||
c._markerDialog = null;
|
||||
return o;
|
||||
}));
|
||||
|
||||
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
await finish(browser, out);
|
||||
@@ -1,67 +0,0 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
// два подопытных light-entity
|
||||
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
|
||||
out.twoLights = lights.length === 2;
|
||||
const setSt = async (m) => {
|
||||
const st = { ...c.hass.states };
|
||||
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
};
|
||||
// виртуальный маркер-выключатель с controls и tap_action=toggle
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// 1) все выключены → клик включает все
|
||||
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
|
||||
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
|
||||
// 2) частично включено → клик выключает все
|
||||
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
||||
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const n = calls.length;
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.infoNoToggle = calls.length === n && !!c._infoCard;
|
||||
// 5) инфо-карточка показывает цели
|
||||
await c.updateComplete;
|
||||
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
// 6) замок в controls отфильтрован при клике (защита)
|
||||
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
const last = calls.at(-1);
|
||||
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,197 +0,0 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + the .covermove ring (kept, the
|
||||
// owner approved it 2026-08-03)
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('covermove');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
o.openNoRing = !cls().includes('covermove');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('covermove');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const cs = getComputedStyle(devEl(), '::after');
|
||||
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('covermove');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('covermove');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,234 +0,0 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
|
||||
// hidden and `switch` above `cover`, so the marker's primary was the service
|
||||
// switch — the tap then resolved on the domain `switch` and the action the
|
||||
// user had explicitly chosen degraded to the info card, while the dialog kept
|
||||
// OFFERING the action, because that check already looked at every entity of
|
||||
// the device. The tap now finds the cover the same way the climate
|
||||
// temperature finds the thermostat: among all of them.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
||||
// meaningfully the light, and nothing below may take that away from it.
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
// park both extra markers in their own corners: the DOM assertions below
|
||||
// find a marker by its position, and the auto grid would sit them on top
|
||||
// of the demo house's own bedroom devices
|
||||
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
if (cu && mx) {
|
||||
c._layout = { ...c._layout,
|
||||
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// the premise of the whole report: the cover is NOT the primary entity
|
||||
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
const setSwitch = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('covermove');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
|
||||
|
||||
// the service switch never speaks for the marker again: reverse-direction
|
||||
// ON used to paint the curtain yellow («включено») while it stood still
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
||||
|
||||
// THE BOUNDARY of the rule: take the explicit action away and the marker
|
||||
// goes back to its primary entity — no cover indication is invented
|
||||
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
savedMarker.tap_action = 'info';
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
|
||||
&& !clsOf(curtain()).includes('covermove');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
||||
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
|
||||
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
|
||||
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
|
||||
// the finding cannot come back through the other door.
|
||||
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
||||
|
||||
// ---- a MIXED device is not hijacked ------------------------------------
|
||||
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
||||
// on this marker, so nothing changes for it — and even if there were one,
|
||||
// a shining light still wins the yellow plate (the glow and the badge may
|
||||
// never disagree, owner's principle 2026-07-29).
|
||||
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
||||
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
||||
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('covermove');
|
||||
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
||||
o.mixedIconIsNotMorphedByTheCover =
|
||||
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,207 +0,0 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: null,
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a cover entity with no state at all is simply neutral, not yellow
|
||||
o.litLampNoCoverStateIsNeutral = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === '';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'covermove';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('covermove');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,196 +0,0 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// While travelling the icon breathes a soft ring (.covermove, the vacuum
|
||||
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
/** Marker for the gate device: tap action + optional confirmation.
|
||||
* `solo` also hides the mower, leaving exactly one badge in the Garden —
|
||||
* the indication checks then read the cover's own plate, not a neighbour's. */
|
||||
const setMarker = async (tapAction, confirm = false, solo = false) => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
|
||||
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
|
||||
// never throw when the feature is missing: the smoke must REPORT the gap
|
||||
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
|
||||
// the gate is the only cover in the Garden space; the plan draws no names
|
||||
// inside the badge, so the icon set of the space is the readable handle
|
||||
const devEl = () => sr().querySelector('.dev');
|
||||
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
|
||||
|
||||
// the gate lives in the Garden space — look at it, not at the ground floor
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('.dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ---- the tap itself ----------------------------------------------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
|
||||
|
||||
// a half-drawn curtain is still 'open' for HA — it closes
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.ajarCloses = lastCall()[1] === 'close_cover';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.closingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
// no readable state — a plain cover.toggle, nothing clever
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
|
||||
|
||||
// ---- the confirmation checkbox works for this action too ---------------
|
||||
await setMarker('cover', true);
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmCancelCallsNothing = calls.length === 0;
|
||||
await tap();
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl();
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el, '::after');
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.covermove').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.covermove').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,200 +0,0 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
out.threeTabs = tabs.length === 3;
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
// seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus
|
||||
// «Картинка-подложка», which f1 offers because it HAS a picture
|
||||
// (docs/BACKDROP.md §2); a hand-drawn space still shows seven
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 8;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
|
||||
await c.updateComplete;
|
||||
out.draftPreview = !!sr().querySelector('.ddraft');
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
|
||||
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
|
||||
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
|
||||
// 3) линия и овал
|
||||
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
|
||||
await c.updateComplete;
|
||||
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
|
||||
const draftLine = sr().querySelector('.decorlayer line.ddraft');
|
||||
out.draftLineRound = !!draftLine
|
||||
&& draftLine.getAttribute('stroke-linecap') === 'round'
|
||||
&& draftLine.getAttribute('stroke-linejoin') === 'round';
|
||||
c._decorCommitDraft();
|
||||
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.threeShapes = c._decorList.length === 3;
|
||||
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
|
||||
// стык двух линий под углом — без зубца (скриншот владельца)
|
||||
await c.updateComplete;
|
||||
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
|
||||
out.savedLineRound = lines.length > 0 && lines.every((l) =>
|
||||
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
|
||||
// вырожденная фигура не сохраняется
|
||||
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
|
||||
c._decorCommitDraft();
|
||||
out.degenerateSkipped = c._decorList.length === 3;
|
||||
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
|
||||
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
|
||||
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
|
||||
const label = () => sr().querySelector('.measurelabel.dmeasure');
|
||||
const txt = () => (label() ? label().textContent.trim() : null);
|
||||
const cellCm = 5; // cell_cm по умолчанию
|
||||
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
|
||||
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
|
||||
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
|
||||
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
|
||||
// 0° кратен 45° — плашка зелёная, как у стены на 45°
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
|
||||
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
|
||||
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
|
||||
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
|
||||
// отпустили — плашки нет
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.badgeGoneAfterRelease = !label();
|
||||
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
|
||||
c._decorTool = 'select';
|
||||
await c.updateComplete;
|
||||
out.stillThreeShapes = c._decorList.length === 3;
|
||||
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
|
||||
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
|
||||
// читается как начальный масштаб.
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
// 5) select: перемещение сохраняет форму, drag двигает
|
||||
const rect = c._decorList.find((x) => x.kind === 'rect');
|
||||
c._decorTool = 'select';
|
||||
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
|
||||
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
|
||||
const before = { ...c._decorList.find((x) => x.id === rect.id) };
|
||||
c._decorMove.start = [0, 0];
|
||||
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
|
||||
const m = c._decorMove; const dx5 = (g * 5) / 1000;
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
|
||||
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
|
||||
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
// экранные координаты точки холста (обратное _screenToVb)
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const press = (el, x, y, pid) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
|
||||
};
|
||||
// подопытная линия с известными концами
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
|
||||
const decorBefore = c._decorList.slice();
|
||||
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
|
||||
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
|
||||
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
|
||||
out.probeRendered = !!probe();
|
||||
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
|
||||
c._decorTool = 'line'; await c.updateComplete;
|
||||
// фигура вообще перестаёт быть мишенью для указателя
|
||||
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
|
||||
press(probe(), g * 18, g * 30, 41);
|
||||
await c.updateComplete;
|
||||
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
|
||||
// ...и именно из этой точки (снап к концу старой линии)
|
||||
out.draftStartsAtTheEnd = !!c._decorDraft
|
||||
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
|
||||
out.lineToolNoSelect = c._decorSel === null;
|
||||
out.lineToolNoMove = !c._decorMove;
|
||||
c._decorDraft = null; await c.updateComplete;
|
||||
// — прямоугольник и овал ведут себя так же
|
||||
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
|
||||
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 42);
|
||||
await c.updateComplete;
|
||||
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
|
||||
c._decorDraft = null;
|
||||
}
|
||||
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
|
||||
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
|
||||
// единственное исключение — надпись под инструментом «текст», она открывает
|
||||
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 43);
|
||||
await c.updateComplete;
|
||||
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
|
||||
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
|
||||
press(probe(), g * 12, g * 30, 44);
|
||||
await c.updateComplete;
|
||||
out.selectStillSelects = c._decorSel === 'dcprobe';
|
||||
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
|
||||
out.selectMakesNoDraft = !c._decorDraft;
|
||||
c._decorMove = null; c._decorSel = null;
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
|
||||
out.eraseWorks = c._decorList.length === n0 - 1;
|
||||
// 7) Esc-лестница: инструмент → select → выход
|
||||
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
|
||||
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
|
||||
await esc(); out.escExit = c._mode === 'view';
|
||||
// 8) в Просмотре фигуры видны, но inert
|
||||
const shp = sr().querySelector('.decorlayer .dshape');
|
||||
out.visibleInView = !!shp;
|
||||
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
|
||||
// 9) Delete удаляет выбранное
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
|
||||
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
|
||||
await finish(browser, res);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user