Compare commits

...
39 Commits
Author SHA1 Message Date
Matysh 2219700d63 Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s
2026-08-08 17:39:25 +03:00
Matysh 854a6944a0 test: align smokes with active registry projection 2026-08-08 15:55:33 +03:00
Matysh 2a8302f4d6 v1.60.2-beta.3: unify boundaries and device presentation 2026-08-08 15:46:07 +03:00
Matysh f1537b2108 v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s
2026-08-08 00:12:47 +03:00
Matysh 5e1315f61d ci: announce releases and prereleases in the Telegram chat
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 3m38s
Validate / smoke (push) Failing after 53s
Validate / backend (push) Failing after 7m14s
2026-08-07 22:35:10 +03:00
Matysh 4e29db1afb test: settle zoom baseline after editor collapse 2026-08-07 22:18:03 +03:00
Matysh 953063b984 fix: keep wall thickness beside room outline 2026-08-07 22:09:42 +03:00
Matysh d48d220a8c v1.60.2-beta.1: add persistent physical geometry 2026-08-07 22:02:41 +03:00
Matysh 1c949ae49d test: poll navigation background transitions
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 4m38s
Validate / backend (push) Failing after 10m49s
Validate / smoke (push) Failing after 25m55s
2026-08-07 14:39:24 +03:00
Matysh 9956a6cfe6 test: settle navigation transitions in browser smokes 2026-08-07 14:31:02 +03:00
Matysh fe5f5b6a24 v1.60.1-beta.1: harden editing and device state 2026-08-07 14:19:02 +03:00
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
Matysh 29fb9deb43 v1.60.0-beta.1: unify background editing and device deletion 2026-08-07 11:14:20 +03:00
Matysh 6a9122f41f v1.59.2: make dialogs accessible 2026-08-07 07:47:37 +03:00
Matysh 25f43da1bd v1.59.1: unify device and light state
Validate / smoke (push) Failing after 19m18s
Validate / hacs (push) Failing after 1m11s
Validate / hassfest (push) Failing after 1m37s
Validate / frontend (push) Successful in 3m0s
Validate / backend (push) Failing after 15m20s
2026-08-06 21:45:18 +03:00
Matysh e7aca9c678 v1.59.0: release stable plan editing 2026-08-06 17:43:53 +03:00
Matysh 5ca4c7e5c5 v1.59.0-rc.2: make plan editing predictable 2026-08-06 16:48:45 +03:00
Matysh e0f6746d7f v1.59.0-rc.1: optimize plans and polish editor feedback
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m12s
Validate / backend (push) Failing after 8m53s
Validate / smoke (push) Failing after 13m51s
2026-08-06 10:14:52 +03:00
Matysh d2bec266ed v1.59.0-beta.10: unify device visuals and wall refinements
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m19s
Validate / smoke (push) Failing after 1m12s
Validate / backend (push) Failing after 7m45s
2026-08-05 23:38:01 +03:00
Matysh 4868cc0786 v1.59.0-beta.9: fix mixed-wall resize and virtual T-junctions
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 10s
Validate / frontend (push) Successful in 3m1s
Validate / backend (push) Failing after 14m22s
Validate / smoke (push) Failing after 19m22s
2026-08-05 20:44:26 +03:00
Matysh e188f9d609 v1.59.0-beta.8: audit follow-ups and inner-corner sun rays 2026-08-05 20:07:27 +03:00
MatyshandCursor 3ad4e9d803 docs: drop unshipped release-gate bullet from beta.7 notes
AUD-159B6-05 needs a workflow-scoped push of release.yml; it is not in the tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:25 +03:00
MatyshandCursor 7333223a55 v1.59.0-beta.7: audit fixes, wall fill under hatch
Atomic wall intervals, open_spans in resize/Undo/Split/Delete, backend
open_spans schema, warm-nav ownership, smoke hygiene.
Wall fill colour paints under the hatch (both, not either/or).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:52:26 +03:00
MatyshandCursor de5e8129a1 v1.59.0-beta.6: partial open spans and wall-centric Delete
Two-click openwall writes space.open_spans; openings forbidden on virtual;
Delete closes virtual then merges or deletes rooms with confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:07:23 +03:00
MatyshandCursor b44d2fb958 docs: design for open spans and wall-centric Delete
Capture brainstorming decisions for partial virtual walls and Delete merge/room flow before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:53:10 +03:00
MatyshandCursor ee87d3d00e v1.59.0-beta.5: wall-thickness redesign, draw thickness, plan on new space
Seamless ±½ wall rings with inner floor/area/sun; Draw toolbar thickness
(default 15 cm); new spaces open Plan; audit hygiene from beta.4 recheck.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:13:19 +03:00
MatyshandCursor a7d956a072 v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
Validate / backend (push) Failing after 9m24s
Validate / smoke (push) Failing after 22m47s
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 9s
Validate / frontend (push) Successful in 2m58s
Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:27:09 +03:00
MatyshandCursor 309bd59358 v1.59.0-beta.3: furniture, hide layers, styling hooks
Third 1.59 pre-release: top-view furniture at real size, hide-decor /
hide-openings toggles, stable card-mod data-* hooks, HA value formatting,
editor polish, and the audit P0/P3 follow-ups. Wall thickness is spec-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:46:11 +03:00
MatyshandCursor 31cd4142ac feat(dev): furniture, hide layers, styling hooks, wall-thickness spec
Ship the unreleased 1.59 batch on dev: top-view furniture in the decor
layer, space toggles to hide decor/openings, stable card-mod data-*
hooks, HA entity value formatting, and the approved wall-thickness
spec (docs only — not implemented yet).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:41:00 +03:00
Cursor AgentandMatysh cb2b064c6a docs(AGENTS): note local houseplan-dev path is invisible to cloud agents
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:26:42 +00:00
Cursor AgentandMatysh e6579f1e55 fix(dev): audit P0/P3 — write policy, README diff, validation
- Default admin_only on; config/get returns can_write; card editors follow it
  and fail closed without hass.user (P0-4).
- README EN/RU differentiation vs GUI draw cards / easy-floorplan (P0-3).
- Tighten marker binding, ripple_color, decor extents, space id (P3-4).
- quality_scale test path + deprecated card tap_action note (P3-5).
- Demo hass.user + can_write; unique marker id in upload overwrite test.

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:10:51 +00:00
Cursor AgentandMatysh 1b37427f1d merge main: AGENTS.md + audit pack into dev
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:05:03 +00:00
MatyshandGitHub 4c260f0ea0 Merge pull request #5 from Matysh/cursor/project-audit-6009
Validate / frontend (push) Successful in 2m3s
Validate / smoke (push) Failing after 20m45s
Validate / hacs (push) Failing after 11s
Validate / backend (push) Failing after 7m50s
Validate / hassfest (push) Failing after 11s
docs: полный аудит проекта (рынок, качество, пробелы, рекомендации)
2026-08-05 11:04:55 +03:00
MatyshandGitHub 159f4f43c0 Merge pull request #4 from Matysh/cursor/setup-dev-environment-6009
chore: document Cursor Cloud dev environment setup
2026-08-05 11:04:53 +03:00
Cursor AgentandMatysh 99f7c3a4a9 docs: full project audit pack for agents (market, quality, gaps)
Add docs/AUDIT*.md covering competitive landscape (easy-floorplan 11→430★),
implementation quality, functional integrity, and P0–P3 recommendations.
Refresh PRODUCT.md competitor claim and point STATUS watchlist at the pack.

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:56:01 +00:00
Cursor AgentandMatysh f4e5ce6822 chore: add Cursor Cloud dev setup notes and ignore backend venv
- Add AGENTS.md documenting the card + integration + demo harness,
  and non-obvious cloud caveats (Python 3.13 backend venv for HA-harness
  tests, fresh-bundle copy before smoke, demo render nudge, known
  pixel-precision smoke).
- Ignore .venv-backend/ (Python 3.13 venv provisioned by the update script).

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:45:11 +00:00
Matysh 0ee80a6a52 v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s
2026-08-04 23:44:59 +03:00
Matysh 1397a71f84 v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / smoke (push) Failing after 22m41s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
2026-08-04 18:20:47 +03:00
202 changed files with 43715 additions and 5085 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Fires on every published release, prereleases included: the workflow file
# lives at the TAGGED commit, so it works for beta tags cut from dev as soon
# as this file is on dev, and for stable tags once it reaches main.
# workflow_dispatch exists purely as a connectivity test button.
on:
release:
types: [published]
workflow_dispatch: {}
permissions: {}
jobs:
telegram:
runs-on: ubuntu-latest
steps:
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ github.event.release.tag_name }}
NAME: ${{ github.event.release.name }}
URL: ${{ github.event.release.html_url }}
PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
fi
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
+59 -1
View File
@@ -4,11 +4,43 @@ on:
types: [published]
permissions:
contents: write
actions: read
jobs:
build:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
build:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
@@ -17,3 +49,29 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: dist/houseplan-card.js
hacs-discovery:
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ github.event.release.prerelease }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v7
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
`Use an rc/new version line or correct the release ordering before announcing the update.`,
);
}
+6 -4
View File
@@ -32,8 +32,10 @@ jobs:
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
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/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.
@@ -47,8 +49,8 @@ jobs:
- 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)
# Never depend on a previous job's artefact; build the exact source
# under test again before the browser suite.
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
+1
View File
@@ -4,3 +4,4 @@ test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
+27
View File
@@ -0,0 +1,27 @@
# 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, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons 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` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
- **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 snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. 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`.
+46 -18
View File
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
@@ -84,16 +89,27 @@ The integration consists of two parts that are installed together:
## How it differs from alternatives
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
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 | Typical solutions (picture-elements / ha-floorplan) |
|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
| **Adding devices** | Automatic, by room | You type in every entity by hand |
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
| | 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 general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Key advantages in short:
@@ -102,11 +118,17 @@ Key advantages in short:
- **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 light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -188,7 +210,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -212,15 +234,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
- **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
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
@@ -231,7 +255,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
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.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -280,6 +304,10 @@ turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
@@ -313,7 +341,7 @@ Services → House Plan**.
**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.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
+43 -20
View File
@@ -6,6 +6,8 @@
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
@@ -13,6 +15,11 @@
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
@@ -29,7 +36,7 @@
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
@@ -81,16 +88,26 @@ House Plan показывает ваш умный дом так, как он в
## Чем отличается от аналогов
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
| | 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 живая карта дома, а не
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
@@ -99,12 +116,14 @@ House Plan показывает ваш умный дом так, как он в
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -188,7 +207,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -212,15 +231,15 @@ title: План дома
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
@@ -231,7 +250,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -282,6 +301,10 @@ title: План дома
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
@@ -314,7 +337,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+45 -2
View File
@@ -126,6 +126,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
lay_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "geom_pending")
}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
@@ -134,14 +138,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
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}
{**lay_meta, "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})
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -149,6 +153,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
if stored.get("config") != target_config:
config_rev += 1
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout:
layout_rev += 1
layout_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
}
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
await data.store.async_save({
**layout_meta,
"layout": target_layout,
"rev": layout_rev,
})
optimize_revs = (config_rev, layout_rev)
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
+4 -1
View File
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
entry = get_entry(hass)
if entry is None:
return is_admin
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
# 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
+3 -2
View File
@@ -18,7 +18,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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=False): bool}),
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
)
@staticmethod
@@ -32,7 +32,8 @@ class HouseplanOptionsFlow(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
# 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 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.58.0"
VERSION = "1.60.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
"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")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
File diff suppressed because one or more lines are too long
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
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
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.58.0"
"version": "1.60.2"
}
@@ -20,7 +20,7 @@ rules:
status: done
config-flow-test-coverage:
status: done
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
dependency-transparency:
status: done
comment: No external requirements.
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
+42 -10
View File
@@ -90,6 +90,10 @@ class TrailBook:
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
@@ -131,6 +135,8 @@ class TrailRecorder:
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
@@ -141,16 +147,7 @@ class TrailRecorder:
# 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
)
self._resubscribe()
# 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
@@ -158,6 +155,41 @@ class TrailRecorder:
for src in self.pairs:
self._sample(src, time.time())
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
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 and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
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
+305 -18
View File
@@ -68,6 +68,15 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
MAX_OPEN_SPANS = 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
@@ -90,6 +99,8 @@ MAX_URL = 2000
# 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
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
@@ -142,6 +153,45 @@ def _view_box(value):
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
# A virtual stretch shorter than this is a click, not a span. Mirrors
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
OPEN_SPAN_MIN_LEN = 1e-3
def _open_span(value):
"""One virtual stretch: exactly two distinct finite points a/b.
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
passed the backend and the card crashed reading `e.a[0]` on render, for
every reader of that space. A degenerate span (a == b) is not a stretch
either: it can never be hit, closed or drawn, so it is corruption, not data.
"""
if not isinstance(value, dict):
raise vol.Invalid("open_span must be an object with a/b points")
a = POINT(value.get("a"))
b = POINT(value.get("b"))
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
raise vol.Invalid("open_span: a and b must differ")
out = {k: v for k, v in value.items() if k not in ("a", "b")}
out["a"] = a
out["b"] = b
return out
def _dedupe_open_spans(value):
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
seen = set()
out = []
for span in value:
a, b = span["a"], span["b"]
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
if key in seen:
continue
seen.add(key)
out.append(span)
return out
def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
@@ -207,6 +257,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
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,
@@ -220,9 +273,43 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
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 inline-reference template is bounded. Attribute
# and unit bounds below apply only to legacy beta.9 link fields, which remain
# accepted so an older saved plan can reach the frontend and migrate on edit.
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
DECOR_TEXT_CM_MAX = 2000.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("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
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):
@@ -231,24 +318,181 @@ _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},
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
vol.Optional("fill"): bool},
# 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("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
# 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"]),
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
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)),
# Legacy one-value link (beta.9 and earlier). New labels store
# every `{entity[:attribute]}` reference directly in `text`;
# these stay accepted solely for backward compatibility.
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(
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
raise vol.Invalid("wall exact endpoints require both a and b")
return entry
WALL_SCHEMA = 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)),
# New writes retain exact normalized interval endpoints. The old
# key remains the compatibility lookup; endpoints preserve a
# differing-thickness breakpoint after a virtual span is closed.
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_endpoints_pair,
)
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
raise vol.Invalid("room draft consecutive points must differ")
return value
ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
},
extra=vol.ALLOW_EXTRA,
),
_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("partition endpoints must differ")
return value
PARTITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_partition_nonzero,
)
def _strict_wall_column(value: dict) -> dict:
"""Reject shape-inapplicable or non-canonical column fields."""
if value["shape"] == "circle" and "angle" in value:
raise vol.Invalid("angle is allowed only for square wall columns")
if value["shape"] == "square" and value.get("angle", 0) >= 90:
raise vol.Invalid("square wall column angle must be in [0, 90)")
return value
WALL_COLUMN_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("shape"): vol.In(["square", "circle"]),
vol.Required("center"): POINT,
# Outer side for a square, outer diameter for a circle.
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
vol.Optional("angle"): vol.All(
_finite, vol.Range(min=0, max=90)
),
},
extra=vol.ALLOW_EXTRA,
),
_strict_wall_column,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
continue
if item_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(item_id)
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
return value
SPACE_SCHEMA = vol.All(vol.Schema(
{
vol.Required("id"): str,
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
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
@@ -260,10 +504,10 @@ SPACE_SCHEMA = vol.Schema(
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
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, 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),
@@ -271,6 +515,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
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)),
@@ -278,7 +533,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -294,6 +549,26 @@ SPACE_SCHEMA = vol.Schema(
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([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
),
vol.Optional("wall_columns"): vol.All(
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
# keeps working on its own (AUD-159B6-03).
vol.Optional("open_spans"): vol.All(
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
),
# 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.
@@ -303,15 +578,22 @@ SPACE_SCHEMA = vol.Schema(
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
vol.Required("binding"): str,
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,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
@@ -348,10 +630,12 @@ MARKER_SCHEMA = vol.Schema(
# 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(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", 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),
@@ -375,6 +659,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
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)),
+329 -7
View File
@@ -7,6 +7,7 @@ import base64
import binascii
import json
import secrets
import time
from pathlib import Path
from typing import Any
@@ -36,18 +37,51 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_OPTIMIZE_BACKUP = "optimize_backup"
_OPTIMIZE_PENDING = "optimize_pending"
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone only before any later plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
try:
return (
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
)
except (TypeError, ValueError):
return False
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await rt.store.async_save({
**{
k: v for k, v in data.items()
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
})
@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_trail_delete)
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_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
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)
@@ -77,6 +111,36 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
# ---------------- layout ----------------
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
"""Drop positions which a deleted marker can no longer own.
HA device ids may be layout-only because auto-discovered devices need no
marker entry. Virtual ids are different: every live virtual marker is
explicit, so a missing `v_*` owner is stale data. The prefix itself is
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
markers = config.get("markers") or []
removed_ids = {
str(m.get("id")) for m in markers if m.get("removed") is True
}
virtual_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") == "virtual"
}
explicit_live_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") != "virtual"
}
return {
marker_id: pos for marker_id, pos in layout.items()
if marker_id not in removed_ids
and (not marker_id.startswith("v_")
or marker_id in virtual_ids
or marker_id in explicit_live_ids)
}
@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:
@@ -85,8 +149,13 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
msg["id"], {
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
}
)
@@ -113,6 +182,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
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:
@@ -120,9 +190,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
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})
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"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})
@@ -144,6 +216,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if rt is None:
return
async with rt.write_lock:
# A stale browser may still finish a drag after another client deleted
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
for m in markers
)
live_virtual = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
and m.get("binding") == "virtual"
for m in markers
)
live_explicit = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
for m in markers
)
orphan_virtual = (
msg["device_id"].startswith("v_")
and not live_virtual
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
@@ -151,7 +256,8 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# 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")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"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})
@@ -548,7 +654,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
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")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
@@ -561,13 +668,27 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@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 and its revision."""
"""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 {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
},
)
@@ -679,6 +800,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
# 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
@@ -704,6 +829,184 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- whole-plan maintenance ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Atomically-intended replacement of config+layout with one-deep undo.
Home Assistant stores are separate files, so a literal cross-file
transaction is impossible. Persisting the target as an intent before
either half changes makes a crash resumable during the next setup; the UI
only receives success once both halves are durable.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
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:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(
msg["id"], "conflict",
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
)
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
config_data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"clear_backup": False,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": msg["layout"],
"rev": new_layout_rev,
_OPTIMIZE_BACKUP: backup,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": True,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize_undo",
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Restore the snapshot, but never overwrite edits made after optimization."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
if not _optimizer_backup_is_current(config_data, layout_data):
connection.send_error(
msg["id"], "no_backup",
"The optimization backup is unavailable or a later edit made it stale",
)
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"config": restored_config,
"layout": restored_layout,
"clear_backup": True,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": restored_layout,
"rev": new_layout_rev,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": False,
})
# ---------------- plan uploads ----------------
@@ -785,3 +1088,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
"""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 {}})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/trail/delete",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
}
)
@websocket_api.async_response
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Permanently forget one deleted marker's current and previous runs."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
return
if _runtime(hass, connection, msg["id"]) is None:
return
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+7 -4
View File
@@ -1,11 +1,14 @@
// Capture: a curtain on the move — the breathing ring (.covermove) around a
// Capture: a curtain on the move — the semantic transition ring 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._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
{ 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',
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(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();
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
+4 -3
View File
@@ -1,7 +1,7 @@
// 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
// and the two travelling ones add the breathing transition 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);
@@ -22,7 +22,8 @@ await page.evaluate(async (STATES) => {
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' })),
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
tap_action: 'cover', display: 'icon_ripple' })),
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
] };
@@ -37,7 +38,7 @@ await page.evaluate(async (STATES) => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
+63
View File
@@ -0,0 +1,63 @@
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
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);
+5 -2
View File
@@ -2,6 +2,9 @@ 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.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
// the flash is keyed to a WITNESSED off→on transition (owner's rule
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
const set = async (state) => {
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
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;}';
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
(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 el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
+2 -1
View File
@@ -46,7 +46,8 @@ await page.evaluate(async () => {
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+64
View File
@@ -0,0 +1,64 @@
// 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);
+74 -24
View File
@@ -7,7 +7,7 @@
* • 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;
* • position and size always land on the grid, including with Shift;
* • 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;
@@ -25,16 +25,23 @@ const restore = () => page.evaluate((s) => {
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const mode = async (m) => {
await page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
}, m);
await page.waitForTimeout(220); // editor chrome transition owns stage geometry
};
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 };
return {
x: sp.plan_x ?? null, y: sp.plan_y ?? null,
sx: sp.plan_scale_x ?? null, sy: sp.plan_scale_y ?? null,
legacy: sp.plan_scale ?? null, angle: sp.plan_angle ?? null,
};
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
@@ -84,20 +91,24 @@ const tool = (t) => page.evaluate((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);
// Every Background session starts predictably on Select. The image frame is
// exclusive to its own tool and therefore cannot steal a Select/pan gesture.
check('no_frame_until_picture_tool', await q('.bdframe'), 0);
check('no_handles_until_picture_tool', await q('.bdframe .bdhandle'), 0);
check('no_picture_frame_until_picture_tool', await frameRect(), null);
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'), 7);
check('no_grab_cursor_until_that_tool', await page.evaluate(() =>
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
check('select_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'select');
check('no_grab_cursor_under_select', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
check('four_corners_plus_rotate_under_picture_tool', await q('.bdframe .bdhandle'), 5);
check('frame_hugs_the_picture_under_picture_tool', await frameRect(), BASE);
check('grab_cursor_under_picture_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
@@ -137,7 +148,8 @@ check('badge_gone_after_release', (await badges()).length, 0);
// 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('scale_untouched_by_a_move', near(cfg.sx, 1) && near(cfg.sy, 1), true);
check('legacy_scale_not_written', cfg.legacy, null);
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).
@@ -150,7 +162,7 @@ check('badge_gone_after_release', (await badges()).length, 0);
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 ----------------------------------
// ---------- 3) Shift cannot step off the grid -----------------------------
await restore();
await settle();
{
@@ -165,12 +177,8 @@ await settle();
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);
check('shift_still_lands_on_the_nearest_node', [im.x, im.y], [100, 150]);
check('shift_stays_on_the_lattice', onGrid(im.x) && onGrid(im.y), true);
}
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
@@ -197,9 +205,10 @@ await settle();
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);
check('axis_scales_saved', near(cfg.sx, im.w / BASE.w, 1e-5)
&& near(cfg.sy, im.h / BASE.h, 1e-5), true);
check('proportional_scale_keeps_axes_equal', near(cfg.sx, cfg.sy, 1e-6), true);
check('scaled_width_is_on_the_grid', onGrid(im.x + im.w), 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;
@@ -217,7 +226,9 @@ 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_clears_the_fields', await spaceCfg(), {
x: null, y: null, sx: null, sy: null, legacy: null, angle: null,
});
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
@@ -263,6 +274,45 @@ 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 navSettled = () => new Promise((resolve) => setTimeout(resolve, 220));
const probe = async (mode) => {
c._setMode(mode);
await upd();
await navSettled();
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();
await navSettled();
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;
+14 -3
View File
@@ -13,6 +13,15 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const waitStageBg = async (accept) => {
const deadline = performance.now() + 700;
let value = await stageBg();
while (!accept(value) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
value = await stageBg();
}
return value;
};
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
@@ -48,7 +57,7 @@ const res = await page.evaluate(async () => {
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
@@ -81,10 +90,10 @@ const res = await page.evaluate(async () => {
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
@@ -181,9 +190,11 @@ const paper = await page.evaluate(async () => {
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
+2 -2
View File
@@ -15,7 +15,7 @@ const res = await page.evaluate(async () => {
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
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;
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
+12 -1
View File
@@ -1,5 +1,7 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
await page.setViewportSize({ width: 390, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -12,8 +14,17 @@ const res = await page.evaluate(async () => {
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;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
const footer = sr().querySelector('.infofooter');
const footerBox = footer?.getBoundingClientRect();
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
});
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
+13
View File
@@ -45,6 +45,19 @@ const res = await page.evaluate(async () => {
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
await c.updateComplete;
out.delroomIgnored = !confirmCalled;
// 6) Delete has exactly one semantic result: the clicked room disappears;
// it never closes a virtual boundary or merges a neighbour into it.
const roomCount = c._curSpaceCfg.rooms.length;
const deleteId = room.id;
window.confirm = () => true;
c._deleteRoomClick(c._roomCenter(room));
await c.updateComplete;
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
c._undoGeometry();
await c.updateComplete;
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
&& c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
return out;
});
checkAll(res);
+3 -2
View File
@@ -62,13 +62,14 @@ const res = await page.evaluate(async () => {
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
// scansPerUpdate = 1: active-registry projection is shared by the climate map
// and buildDevices. Важно
// не само число, а что оно не растёт вместе с числом комнат.
checkAll(res, {
scansOnRerender: 0,
roomCount: 44,
scansSameWith44Rooms: true,
scansPerUpdate: 2,
scansPerUpdate: 1,
tempBefore: 22.4,
tempAfter: 33.3,
climateIsMap: true,
+4 -4
View File
@@ -61,12 +61,12 @@ Object.assign(out, await page.evaluate(async () => {
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
o.noCheckboxForThermometer = !sr().querySelector('hp-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');
const row = sr().querySelector('hp-dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
@@ -77,7 +77,7 @@ Object.assign(out, await page.evaluate(async () => {
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
});
check('checkboxClickable', !!box);
@@ -138,7 +138,7 @@ Object.assign(out, await page.evaluate(async () => {
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;
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
+40 -1
View File
@@ -19,7 +19,8 @@ const res = await page.evaluate(async () => {
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' };
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
icon: 'mdi:light-switch', display: 'icon_ripple' };
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);
@@ -36,6 +37,7 @@ const res = await page.evaluate(async () => {
// 3) значок отражает цели: горит одна → класс on
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
out.stateOn = !!c._stateClass(dev2()).includes('on');
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
@@ -55,6 +57,43 @@ const res = await page.evaluate(async () => {
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
// Opening and saving must not rewrite configuration that runtime cannot
// currently execute: YAML-only domains and duplicates are still user data.
const losslessControls = [lights[0], 'input_boolean.yaml_only', lights[0], 'sensor.vendor_option'];
cfg.controls = [...losslessControls];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
c._openMarkerDialog(dev2()); await c.updateComplete;
out.dialogKeepsUnknownAndDuplicateControls = JSON.stringify(c._markerDialog?.controls)
=== JSON.stringify(losslessControls);
await c._saveMarker(); await c.updateComplete;
const savedLossless = c._serverCfg.markers.find((m) => m.name === 'Выключатель')?.controls;
out.saveKeepsUnknownAndDuplicateControls = JSON.stringify(savedLossless)
=== JSON.stringify(losslessControls);
// Legacy configs could store a standalone entity as its own external
// control. That self-reference must neither become a light source/group nor
// disappear behind ambiguous dialog UI. Its normal Toggle action still
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
await setSt({ [own]: 'on' });
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
}
return out;
});
checkAll(res);
+25 -14
View File
@@ -8,8 +8,8 @@
// 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)
// opening / closing -> neutral plate + activity-transition when the
// marker opts into «Icon + activity»
//
// 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
@@ -43,7 +43,7 @@ const out = await page.evaluate(async () => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
],
};
@@ -84,12 +84,14 @@ const out = await page.evaluate(async () => {
};
plateIsNeutral('closed');
o.closedNoRing = !cls().includes('covermove');
o.closedNoRing = !cls().includes('activity-transition');
o.closedIconClosed = icon() === 'mdi:curtains-closed';
await setCover('open', { device_class: 'curtain' });
plateIsNeutral('open');
o.openNoRing = !cls().includes('covermove');
// This fixture jumps closed → open without an intermediate HA state, so
// the unified resolver supplies the agreed ~3.3 s transition fallback.
o.openDirectTransition = cls().includes('activity-transition');
o.openIconOpen = icon() === 'mdi:curtains';
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
@@ -99,15 +101,16 @@ const out = await page.evaluate(async () => {
await setCover('opening', { device_class: 'curtain' });
plateIsNeutral('opening');
o.openingRings = cls().includes('covermove');
o.openingRings = cls().includes('activity-transition');
o.openingRingIsTheBreathingOne = (() => {
const cs = getComputedStyle(devEl(), '::after');
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
})();
await setCover('closing', { device_class: 'curtain' });
plateIsNeutral('closing');
o.closingRings = cls().includes('covermove');
o.closingRings = cls().includes('activity-transition');
// --- the morph covers every class, both ways ---------------------------
const morph = async (dc, closedIcon, openIcon) => {
@@ -135,7 +138,7 @@ const out = await page.evaluate(async () => {
await setCover('unknown', { device_class: 'curtain' });
plateIsNeutral('unknown');
o.unknownNoMorph = icon() === 'mdi:garage-variant';
o.unknownNoRing = !cls().includes('covermove');
o.unknownNoRing = !cls().includes('activity-transition');
// --- NOT touched: the «открыто» frame elsewhere ------------------------
// the demo house has a lock and a window sensor on the ground floor
@@ -178,7 +181,15 @@ const out = await page.evaluate(async () => {
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 entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = c._stateClass(d);
c.hass = saved;
return r;
@@ -186,10 +197,10 @@ const out = await page.evaluate(async () => {
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 };
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
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.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
return o;
});
+22 -26
View File
@@ -1,16 +1,14 @@
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
// switch in registry/domain order.
// (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.
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
// the real cover for status, while the explicit tap action decides whether a
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
// explicitly selects the cover action.
//
// 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.
@@ -77,16 +75,17 @@ const out = await page.evaluate(async () => {
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';
// Registry order and visibility no longer let an option switch outrank the
// actual functional entity.
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
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);
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
@@ -165,7 +164,7 @@ const out = await page.evaluate(async () => {
await push('closed', { device_class: 'curtain' });
o.markerIsOnScreen = !!devEl(curtain());
o.closedIsNeitherOpenNorMoving =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
await push('open', { device_class: 'curtain' });
@@ -176,10 +175,10 @@ const out = await page.evaluate(async () => {
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
await push('opening', { device_class: 'curtain' });
o.travellingBreathes = clsOf(curtain()).includes('covermove');
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
await push('closing', { device_class: 'curtain' });
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
o.closingBreathesToo = clsOf(curtain()).includes('activity-transition');
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('activity-transition');
// the service switch never speaks for the marker again: reverse-direction
// ON used to paint the curtain yellow («включено») while it stood still
@@ -187,28 +186,25 @@ const out = await page.evaluate(async () => {
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
// Status and action are independent: taking the explicit action away keeps
// the resolved cover status, but a click returns to the ordinary info path.
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');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
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.
// The same rule must hold when BOTH entities are visible.
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';
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
calls.length = 0;
await tap();
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
@@ -223,7 +219,7 @@ const out = await page.evaluate(async () => {
// 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');
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('activity-transition');
// 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');
+22 -13
View File
@@ -45,7 +45,15 @@ const out = await page.evaluate(async () => {
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = fn();
c.hass = saved;
return r;
@@ -54,14 +62,14 @@ const out = await page.evaluate(async () => {
/** 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,
tapAction: 'cover', marker: { display: 'icon_ripple' },
};
/** 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'] },
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'], display: 'icon_ripple' },
};
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
const CV = { closed: '', open: '', opening: 'activity-transition', closing: 'activity-transition' };
for (const [state, want] of Object.entries(CV)) {
const states = {
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
@@ -78,14 +86,15 @@ const out = await page.evaluate(async () => {
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({
// …and a selected cover with no state is unavailable, never borrowed from
// the lit neighbour that the explicit cover action deliberately outranks.
o.litLampNoCoverStateIsUnavailable = withStates({
'light.mixed': st('on', {}),
}, () => c._stateClass(litLamp)) === '';
}, () => c._stateClass(litLamp)) === 'unavail';
// ---- 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 };
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null, marker: null };
o.mixedWithoutTheOptionStaysYellow = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === 'on';
@@ -104,7 +113,7 @@ const out = await page.evaluate(async () => {
// 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';
}, () => c._stateClass(controlled)) === 'activity-transition';
// 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 = {
@@ -143,8 +152,8 @@ const out = await page.evaluate(async () => {
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'] },
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'], display: 'icon_ripple' },
],
};
c._cfgEpoch++;
@@ -187,7 +196,7 @@ const out = await page.evaluate(async () => {
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 + 'Rings'] = el.classList.contains('activity-transition');
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
};
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
@@ -200,7 +209,7 @@ const out = await page.evaluate(async () => {
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');
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
return o;
});
checkAll(out);
+15 -14
View File
@@ -10,8 +10,8 @@
// 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 «включено».
// With «Icon + activity», travelling breathes a soft activity ring (the
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
// 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).
@@ -45,7 +45,8 @@ const out = await page.evaluate(async () => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction,
tap_confirm: confirm || null, display: 'icon_ripple' },
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
],
};
@@ -73,13 +74,13 @@ const out = await page.evaluate(async () => {
// ---- 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);
const optionValues = () => [...sr().querySelectorAll('hp-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;
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
@@ -149,9 +150,9 @@ const out = await page.evaluate(async () => {
// ---- the indication ----------------------------------------------------
const afterAnim = () => {
const el = devEl();
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
if (!el) return null;
const cs = getComputedStyle(el, '::after');
const cs = getComputedStyle(el);
return { name: cs.animationName, dur: cs.animationDuration };
};
await setMarker('cover', false, true); // the gate alone on the plan
@@ -160,33 +161,33 @@ const out = await page.evaluate(async () => {
// 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;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && 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.pulseWhileClosing = sr().querySelectorAll('.dev.activity-transition').length === 1;
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('open', { device_class: 'curtain' });
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
o.noPulseWhenOpen = sr().querySelectorAll('.dev.activity-transition').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.directCloseGetsFallback = sr().querySelectorAll('.dev.activity-transition').length === 1;
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.noPulseWhenUnknown = sr().querySelectorAll('.dev.activity-transition').length === 0;
o.noMorphWhenUnknown = icons().includes(gate().icon)
&& !icons().some((i) => i.startsWith('mdi:curtains'));
+128 -9
View File
@@ -11,9 +11,25 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
// six drawing tools + «Картинка-подложка», which f1 offers because it HAS a
// picture (docs/BACKDROP.md §2); a hand-drawn space still shows six
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 7;
// The compact colour/opacity control must escape the clipped editor/dialog
// surface through the browser top layer and remain inside the viewport.
const picker = sr().querySelector('.decorbar hp-color-opacity');
const trigger = picker?.shadowRoot?.querySelector('.trigger');
trigger?.click();
await picker?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const popup = picker?.shadowRoot?.querySelector('.picker');
const popupRect = popup?.getBoundingClientRect();
out.colorPickerUsesTopLayer = !!popup?.matches(':popover-open');
out.colorPickerInsideViewport = !!popupRect
&& popupRect.left >= 0 && popupRect.top >= 0
&& popupRect.right <= innerWidth && popupRect.bottom <= innerHeight;
trigger?.click();
await picker?.updateComplete;
// 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;
@@ -47,6 +63,9 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
c._decorDraft = { kind: 'rect', a: [g, g], b: [g * 5, g], pid: 1 };
c._decorCommitDraft();
out.flatRectSkipped = c._decorList.length === 3;
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
@@ -75,10 +94,11 @@ const res = await page.evaluate(async () => {
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);
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
&& /(?:m²|ft²)$/.test(txt());
// отпустили — плашки нет
c._decorCommitDraft(); await c.updateComplete;
out.badgeGoneAfterRelease = !label();
@@ -86,8 +106,10 @@ const res = await page.evaluate(async () => {
c._decorTool = 'select';
await c.updateComplete;
out.stillThreeShapes = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
// 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('Сауна'));
@@ -151,7 +173,10 @@ const res = await page.evaluate(async () => {
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;
@@ -165,14 +190,108 @@ const res = await page.evaluate(async () => {
out.selectStillSelects = c._decorSel === 'dcprobe';
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
out.selectMakesNoDraft = !c._decorDraft;
c._decorMove = null; c._decorSel = null;
c._decorMove = null;
// Double click in Select is the universal properties gesture for every
// object, not only text. It must go through the rendered listener so this
// also pins the event wiring, then persist the common colour/width fields.
probe().dispatchEvent(new MouseEvent('dblclick', {
bubbles: true, composed: true, cancelable: true, button: 0,
}));
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('hp-dialog .dfill') === false;
out.lineStyleDefaultsSolid = c._decorShapeDialog?.lineStyle === 'solid'
&& sr().querySelectorAll('input[name="decor-line-style"]').length === 2
&& !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
const angleInput = sr().querySelector('hp-dialog input[min="-180"][max="180"]');
if (angleInput) {
angleInput.value = '12.3456';
angleInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.angleInputKeepsWorkingPrecision = c._decorShapeDialog?.angle === '12.3456'
&& sr().querySelector('hp-dialog input[min="-180"][max="180"]')?.value === '12.3456';
out.lineStyleAbsentFromToolbar = !sr().querySelector('.editbar input[name="decor-line-style"]');
c._decorShapeDialog = {
...c._decorShapeDialog, color: '#123456', widthCm: 6.5, lineStyle: 'dashed',
};
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
&& editedProbe?.line_style === 'dashed'
&& c._decorShapeDialog === null;
out.dashedLineRendered = !!probe()?.getAttribute('stroke-dasharray');
c._undoGeometry(); await c.updateComplete;
const undoSolid = !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
c._redoGeometry(); await c.updateComplete;
out.lineStyleUndoRedo = undoSolid
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
const dashedPaint = probe();
let gapPicked = null;
if (selectHit && dashedPaint) {
const sw = Number(dashedPaint.getAttribute('stroke-width')) || 1;
const gapPoint = dashedPaint.getPointAtLength(Math.min(dashedPaint.getTotalLength() * 0.75, sw * 5.5));
const screenPoint = new DOMPoint(gapPoint.x, gapPoint.y).matrixTransform(dashedPaint.getScreenCTM());
gapPicked = sr().elementFromPoint(screenPoint.x, screenPoint.y);
}
out.dashedLineSelectableAcrossGaps = gapPicked === selectHit
&& selectHit?.classList.contains('dshape')
&& selectHit?.dataset.hp === 'decor'
&& selectHit?.dataset.id === 'dcprobe';
// Erase gets a constant screen-space target around hairlines. Exercise the
// browser's actual SVG hit-test 6 px away from a sub-pixel painted stroke;
// dispatching directly to the proxy would only prove that its listener exists.
c._replaceDecor('dcprobe', { width_cm: 0.1, width: undefined });
c._decorTool = 'erase'; await c.updateComplete;
const paintedProbe = sr().querySelector('.decorlayer line.dshape:not(.derasehit)');
const eraseHit = sr().querySelector('.decorlayer line.derasehit');
const matrix = paintedProbe?.getScreenCTM();
let picked = null;
if (paintedProbe && eraseHit && matrix) {
const p1 = new DOMPoint(+paintedProbe.getAttribute('x1'), +paintedProbe.getAttribute('y1')).matrixTransform(matrix);
const p2 = new DOMPoint(+paintedProbe.getAttribute('x2'), +paintedProbe.getAttribute('y2')).matrixTransform(matrix);
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
picked = sr().elementFromPoint((p1.x + p2.x) / 2 - (dy / len) * 6,
(p1.y + p2.y) / 2 + (dx / len) * 6);
}
out.eraseHairlineHasWideHitTarget = picked === eraseHit
&& getComputedStyle(eraseHit).strokeWidth === '16px'
&& getComputedStyle(eraseHit).pointerEvents === 'stroke';
picked?.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 45, button: 0, isPrimary: true,
}));
await c.updateComplete;
out.eraseWideHitOpensConfirmation = c._decorEraseConfirm?.id === 'dcprobe';
c._decorEraseConfirm = null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
const liveBefore = JSON.parse(JSON.stringify(liveShape));
const liveSnapshot = c._geometrySnapshot();
c._decorMove = {
id: liveShape.id, start: [0, 0], orig: liveBefore, pid: 99,
moved: true, before: liveSnapshot,
};
c._curSpaceCfg.decor = c._decorList.map((shape) => shape.id === liveShape.id
? { ...shape, x: (shape.x || 0) + 0.2 }
: shape);
c._setMode('view'); await c.updateComplete;
const restoredLive = c._decorList.find((shape) => shape.id === liveShape.id);
out.modeSwitchCancelsLiveDecor = !c._decorMove
&& JSON.stringify(restoredLive) === JSON.stringify(liveBefore);
c._setMode('decor'); await c.updateComplete;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseAsksConfirmation = c._decorList.length === n0 && !!c._decorEraseConfirm;
c._confirmDecorErase();
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
+226
View File
@@ -0,0 +1,226 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока и
// после явной правки хранится физически в `size_cm`; старый `size`
// до этого продолжает читаться без визуальной миграции;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
// (шаг 5°, Shift — мимо шага); ВИДИМЫЙ размер ручки — четверть хит-зоны
// (правка владельца 2026-08-05 «уменьшить в 4 раза»), сама хит-зона
// прежняя, пальцевая: 1.8 % видимого вида;
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
// вторую надпись поверх первой.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
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 ev = (type, target, x, y, extra = {}) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
// ================= 1. размера шрифта в диалоге больше нет ================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
c._curSpaceCfg.decor = [
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.legacyLarge = +attr('dtl', 'font-size') === 30;
out.legacySmall = +attr('dts', 'font-size') === 14;
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
// ================= 2. многострочный текст, по центру =====================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
c._decorSaveText(); await c.updateComplete;
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
const tsp = tspans(multi.id);
out.twoLinesRendered = tsp.length === 2
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
// блок центрирован и по вертикали: якорь ровно между строками
const ay = multi.y * 1000;
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
open(multi); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
c._decorSaveText(); await c.updateComplete;
out.noAutoWrap = tspans(multi.id).length === 1;
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
c._decorSaveText(); await c.updateComplete;
// ================= 3. ручки масштаба и поворота ==========================
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& !!frame().querySelector('.dtrot');
// --- визуал в 4 раза меньше, хит-зона прежняя (docs/LIVE-TEXT.md §3) ---
out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5;
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
const hitR = rOf('.dthandle.dtrot');
const knobR = rOf('.dtknob');
const vw = c._viewOr(c._baseVb());
const expectHit = Math.max(vw.w, vw.h) * 0.018;
out.hitRadiusUnchanged = Math.abs(hitR - expectHit) < 0.1; // прежние 1.8 %
out.knobIsAQuarterOfTheHit = Math.abs(knobR * 4 - hitR) / Math.max(hitR, 1e-9) < 0.05;
out.knobTakesNoPointer = getComputedStyle(frame().querySelector('.dtknob')).pointerEvents === 'none';
out.handleTakesThePointer = getComputedStyle(frame().querySelector('.dthandle')).pointerEvents !== 'none';
// …и палец, попавший МИМО бусины, но внутрь прежнего круга, всё ещё берёт ручку
const rotEl = frame().querySelector('.dtrot');
const rcx = +rotEl.getAttribute('cx'), rcy = +rotEl.getAttribute('cy');
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
// линия использует общую рамку, но только с двумя ручками концов
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
const lineFrame = sr().querySelector('.dtframe.dtlineframe');
out.lineGetsEndpointFrame = !!lineFrame
&& lineFrame.querySelectorAll('.dthandle.dtendpoint').length === 2
&& !lineFrame.querySelector('.dtrot');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: физический размер растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const mediumSizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtm'));
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` становится физическим `size_cm` только после явной правки
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
const legacySizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtl'));
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesPhysical = grown.size === undefined && grown.scale === undefined
&& Math.abs((grown.size_cm || 0) - legacySizeCm0 * 2) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [rx, ry] = anchor();
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
ev('pointerdown', rot, rx + 100, ry);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
await c.updateComplete;
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
await c.updateComplete;
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((x) => x.id === 'dtm').angle;
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
ev('pointerup', stageEl(), rx + 100, ry + 2);
await c.updateComplete;
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
// …а без Shift тот же 1.1° снова прилипает к нулю
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
ev('pointermove', stageEl(), rx + 100, ry);
ev('pointerup', stageEl(), rx + 100, ry);
await c.updateComplete;
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
// ================= 4. инструмент «текст» по надписи = редактировать =======
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
await c.updateComplete;
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
out.chromiumTextUsesAtomicBoundingBox = getComputedStyle(el('dtm')).pointerEvents === 'bounding-box';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
&& c._decorTextDialog.text === target.text;
out.textToolMakesNoSecondLabel = c._decorList.length === before;
out.textToolDoesNotSelect = c._decorSel === null;
c._decorTextDialog = null; await c.updateComplete;
// по пустому месту — новая надпись
ev('pointerdown', stageEl(), 60, 940);
await c.updateComplete;
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
c._decorTextDialog = null; await c.updateComplete;
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
const lineEl = sr().querySelector('.decorlayer line.dshape');
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
ev('pointerdown', lineEl, 250, 800);
await c.updateComplete;
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
&& c._decorSel === null;
c._decorTextDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+60
View File
@@ -0,0 +1,60 @@
// Shared face parity for the same live light on the full plan, the marker
// editor preview and houseplan-space-card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const face = (node) => {
if (!node) return null;
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
return {
classes: semantic.filter((name) => node.classList.contains(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
temp: node.querySelector('.tval')?.textContent?.trim() || '',
hum: node.querySelector('.hval')?.textContent?.trim() || '',
scale: node.style.getPropertyValue('--dev-scale'),
rippleScale: node.style.getPropertyValue('--ripple-scale'),
rippleColor: node.style.getPropertyValue('--ripple-color'),
};
};
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const planFace = face(sr().querySelector('.dev[data-id="d_light1"]'));
c._setMode('devices');
const device = c._devices.find((item) => item.id === 'd_light1');
c._openMarkerDialog(device);
await c.updateComplete;
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
await customElements.whenDefined('houseplan-space-card');
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = c.hass;
document.body.appendChild(card);
const started = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - started < 6000) await wait(60);
await card.updateComplete;
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
});
checkAll(res);
await finish(browser, res);
+70
View File
@@ -0,0 +1,70 @@
// HA-disabled lifecycle: saved marker -> forced hidden -> service ghost and
// blocked Show -> same marker restored after reactivation, without config writes.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg = {
...c._serverCfg,
markers: [
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_light1'),
{ id: 'd_light1', binding: 'device:d_light1' },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
await paint();
const configBefore = JSON.stringify(c._serverCfg);
const layoutBefore = JSON.stringify(c._layout);
const visibleBefore = !!sr().querySelector('.dev[data-id="d_light1"]');
window.__setRegistryDisabled('device', 'd_light1', 'user');
c.hass = window.__mkHass();
await wait(220);
await paint();
const hiddenInView = !sr().querySelector('.dev[data-id="d_light1"]');
c._showHidden = true;
c._setMode('devices');
c._regSignature = '';
c._maybeRebuildDevices();
await paint();
const ghost = sr().querySelector('.dev[data-id="d_light1"][data-binding-status="ha-disabled"]');
const disabledDevice = c._devices.find((device) => device.id === 'd_light1');
c._openMarkerDialog(disabledDevice);
await paint();
const hiddenBeforeShow = c._markerDialog?.hideFromPlan;
c._toggleMarkerDialogVisibility();
await paint();
const showBlocked = /disabled|deactiv/i.test(c._toast)
&& c._markerDialog?.hideFromPlan === hiddenBeforeShow
&& sr().querySelector('.toast')?.getAttribute('role') === 'alert';
c._markerDialog = null;
window.__setRegistryDisabled('device', 'd_light1', null);
c.hass = window.__mkHass();
await wait(220);
c._showHidden = false;
c._setMode('view');
await paint();
return {
visibleBefore,
hiddenInView,
labelledGhost: !!ghost,
showBlocked,
restored: !!sr().querySelector('.dev[data-id="d_light1"]'),
configUnchanged: JSON.stringify(c._serverCfg) === configBefore,
layoutUnchanged: JSON.stringify(c._layout) === layoutBefore,
};
});
checkAll(res);
await finish(browser, res);
+2 -2
View File
@@ -110,14 +110,14 @@ const out = await page.evaluate(async () => {
c._drag = null;
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
// ---- (f) Shift is the only way to land BETWEEN the nodes --------------
// ---- (f) Shift cannot land between the nodes ---------------------------
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
const r0 = c._pos(dev3);
const halfStep = NORM_W / 240 / 2;
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
c._drag = null;
o.shiftPlacesBetweenTheNodes = !onGrid(c._layout[dev3.id].x);
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
c._setMode('view');
return o;
+98
View File
@@ -0,0 +1,98 @@
/**
* Draw toolbar wall thickness (docs/WALL-THICKNESS.md §6): default 15 cm on
* commit, live preview, shared neighbour cm kept.
*/
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
c._setMode('plan');
c._tool = 'draw';
await upd();
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
const wallsButton = sr().querySelector('.editbar button.on');
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
.includes((wallsButton?.textContent || '').trim());
out.fieldImmediatelyAfterWalls = wallsButton?.nextElementSibling?.matches('label.drawwall') === true;
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
// use empty space if available, else clear rooms on a copy path
const space = sp();
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
const savedWalls = space.walls ? JSON.parse(JSON.stringify(space.walls)) : null;
space.rooms = [];
delete space.walls;
await upd();
const g = c._gridPitch;
// 4×4 cell square
const pts = [
[100, 100], [100 + 8 * g, 100], [100 + 8 * g, 100 + 8 * g], [100, 100 + 8 * g],
];
c._path = [];
c._tool = 'draw';
for (const p of pts) {
c._path = [...c._path, p];
c._cursorPt = p;
await upd();
}
// close
c._path = [...c._path, pts[0]];
await upd();
out.previewWhileOpen = !!sr().querySelector('.drawwall-preview')
|| c._drawWallCm === 15;
// force closed state if helper exists
if (!c._contourClosed) {
// last point equals first — _contourClosed should be true
}
out.contourClosed = !!c._contourClosed;
c._nameSel = 'ThickDraw';
c._areaSel = '';
c._commitRoom();
await upd();
out.roomSaved = (space.rooms || []).some((r) => r.name === 'ThickDraw');
out.wallsApplied = (space.walls || []).length >= 4
&& (space.walls || []).every((w) => w.cm === 15);
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
// second room sharing the right edge — neighbour keeps 15, new edges get 20
c._drawWallField = '20';
const r1 = space.rooms.find((r) => r.name === 'ThickDraw');
const poly = r1.poly.map((p) => [p[0] * 1000, p[1] * (c._spaceH || 1000)]);
// shared vertical: right edge of r1
const x1 = Math.max(...poly.map((p) => p[0]));
const y0 = Math.min(...poly.map((p) => p[1]));
const y1 = Math.max(...poly.map((p) => p[1]));
const w = 6 * g;
c._path = [
[x1, y0], [x1 + w, y0], [x1 + w, y1], [x1, y1], [x1, y0],
];
await upd();
c._nameSel = 'Neighbour';
c._commitRoom();
await upd();
const sharedStill15 = (space.walls || []).some((w) => w.cm === 15);
const has20 = (space.walls || []).some((w) => w.cm === 20);
out.sharedKept = sharedStill15 && has20;
// restore demo space
space.rooms = savedRooms;
if (savedWalls) space.walls = savedWalls;
else delete space.walls;
c._path = [];
await upd();
return out;
});
checkAll(res);
await finish(browser, res);
+102 -1
View File
@@ -19,20 +19,121 @@ const res = await page.evaluate(async () => {
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// Let the View -> editor grid transition finish first. A real user cannot
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
// height animations in that frame are intentionally suppressed.
await new Promise((resolve) => setTimeout(resolve, 220));
// 4) прямое переключение План → Устройства
// Deliberately make the outgoing bar taller: the editor transition must
// interpolate real content height, not merely fade two equal-height rows.
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
.some((animation) => animation.id === 'hp-editor-height-swap');
out.editorSwapAnimatesContent = swapInner.getAnimations()
.some((animation) => animation.id === 'hp-editor-content-swap');
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
&& chrome.getAttribute('aria-hidden') === 'true';
// The last bar deliberately remains mounted while its grid row collapses.
await new Promise((resolve) => setTimeout(resolve, 220));
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
out.tabCrossWorks = c._mode === 'view';
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
let undoCalls = 0;
let redoCalls = 0;
c._undoGeometry = () => { undoCalls += 1; };
c._redoGeometry = () => { redoCalls += 1; };
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.layoutIndependentUndo = undoCalls === 1;
const editorInput = sr().querySelector('.editbar input');
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.inputKeepsNativeUndo = undoCalls === 1;
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
// close-tab shortcut, never an editor Undo.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
c._undoGeometry = realUndo;
c._redoGeometry = realRedo;
// Ctrl+click closes a valid draft without adding the click as a new point;
// the same gesture must refuse a draft with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
preventDefault() { prevented = true; }, composedPath() { return []; },
});
return prevented;
};
c._tool = 'draw';
c._roomDialog = false;
c._path = [[-120, -120], [-80, -120]];
const shortBefore = JSON.stringify(c._path);
out.ctrlCloseRequiresTwoEdges = ctrlClick()
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
c._path = [[-120, -120], [-80, -120], [-80, -80]];
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
&& c._path.length === 4
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
await c.updateComplete;
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
const roomBody = roomDialog?.querySelector('.body');
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+39
View File
@@ -3,6 +3,45 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// A11Y-02: the shared native fallback carries the same modal/focus contract
// that ha-dialog supplies in Home Assistant.
const opener = document.createElement('button');
opener.textContent = 'open settings';
sr.append(opener);
opener.focus();
c._openSettingsDialog(); await c.updateComplete; await frame();
const hp = sr.querySelector('hp-dialog');
const native = hp?.shadowRoot?.querySelector('dialog');
const titleId = native?.getAttribute('aria-labelledby');
out.sharedShell = !!hp && !!native?.open;
out.modalSemantics = native?.getAttribute('role') === 'dialog'
&& native?.getAttribute('aria-modal') === 'true'
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
let active = deepActive();
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
const last = focusable.at(-1);
last?.focus();
last?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab', bubbles: true, composed: true, cancelable: true,
}));
active = deepActive();
out.focusTrap = active?.classList?.contains('close') === true;
active?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete; await frame();
out.restoreFocus = !c._settingsDialog && deepActive() === opener;
opener.remove();
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// общие настройки
c._openSettingsDialog(); await c.updateComplete;
+1 -1
View File
@@ -50,7 +50,7 @@ const res = await page.evaluate(async () => {
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
+98
View File
@@ -0,0 +1,98 @@
// Browser contract for saved room drafts, independent partitions and columns.
// This deliberately exercises the card's editor transactions rather than
// injecting finished records: create, drag, edit and Delete must all pass
// through the same paths a pointer/keyboard gesture uses.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const PITCH = 1000 / 240;
window.confirm = () => true;
c._serverCfg = {
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [] }],
markers: [], settings: {},
};
c._space = 'physical';
c._mode = 'plan';
c._modelCache = null;
// Keep the smoke local: editor transactions still mutate the real config,
// invalidate derived models and history, but do not need a websocket write.
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
await c.updateComplete;
c._tool = 'partition';
c._drawWallField = '15';
c._partitionClick([100, 100], false);
c._partitionClick([300, 100], false);
let sp = c._serverCfg.spaces[0];
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
c._tool = 'column';
c._drawWallField = '30';
c._columnClick([200, 200]);
sp = c._serverCfg.spaces[0];
o.columnCreatedSquare = sp.wall_columns?.length === 1
&& sp.wall_columns[0].shape === 'square' && sp.wall_columns[0].cm === 30;
c._tool = 'draw';
c._drawWallField = '12';
c._path = [[100, 300], [200, 300]];
c._activeDraftId = null;
c._draftSegmentCms = [];
c._persistActiveDraftSegment();
sp = c._serverCfg.spaces[0];
o.draftPersistedImmediately = sp.room_drafts?.length === 1
&& sp.room_drafts[0].segments[0].cm === 12;
// Endpoint-to-endpoint join is one atomic record: the active id survives
// and segment thickness follows geometric order.
sp.room_drafts = [
{ id: 'join-a', points: [[0.1, 0.4], [0.3, 0.4]], segments: [{ cm: 10 }] },
{ id: 'join-b', points: [[0.3, 0.4], [0.5, 0.4]], segments: [{ cm: 20 }] },
];
c._modelCache = null;
c._activeDraftId = 'join-a';
c._path = [[100, 400], [300, 400]];
c._draftSegmentCms = [10];
const join = c._draftEndAt([300, 400], 'join-a');
if (join) c._mergeDraftEndpoint(join);
sp = c._serverCfg.spaces[0];
o.draftEndpointJoinIsAtomic = sp.room_drafts?.length === 1
&& sp.room_drafts[0].id === 'join-a'
&& JSON.stringify(sp.room_drafts[0].segments.map((segment) => segment.cm)) === '[10,20]';
const part = c._spaceModel().partitions[0];
c._physicalDrag = {
pid: 71, kind: 'partition', id: part.id, start: [...part.a],
startClient: [0, 0], before: c._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(part)), delta: [PITCH, PITCH],
};
c._physicalUp(new PointerEvent('pointerup', { pointerId: 71, bubbles: true }));
sp = c._serverCfg.spaces[0];
o.partitionDragIsRigidAndGridBound = Math.abs(sp.partitions[0].a[0] - (0.1 + PITCH / 1000)) < 1e-9
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
c._deletePhysicalSelection();
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
c._deletePhysicalSelection();
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
c._tool = 'partition';
c._drawWallField = '120';
c._partitionClick([400, 100], false);
c._partitionClick([500, 100], false);
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
return o;
});
await finish(browser, checkAll(out));
+284
View File
@@ -0,0 +1,284 @@
// Библиотека мебели в декор-слое (docs/FURNITURE.md, задача владельца №2):
// 1) в панели декора есть инструмент «Мебель», он открывает ПАЛИТРУ с
// обязательными группами (мебель / техника / сантехника) и превью;
// 2) выбранный символ ставится кликом по плану в РЕАЛЬНОМ размере — через
// cell_cm пространства, и сразу оказывается выделенным в «выбрать»;
// 3) поля ширины/глубины в палитре меняют размер ДО размещения;
// 4) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
// по ней; Shift не отключает обязательную привязку (docs/CANVAS.md §9.4);
// 5) у выделенного предмета та же рамка, что у текстового блока, — угловые
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
// 6) во время растягивания угла показываются живые плашки размеров;
// 7) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
// kind:'furniture' не рисуется, магнита и плашек не существует. Смок при этом
// null-safe — он показывает СПИСОК провалов, а не одно исключение.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stageEl = () => sr().querySelector('.stage');
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
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 ev = (type, target, x, y, extra = {}) => {
// null-safe: на сборке ДО этой задачи фигуры нет вовсе, а значит нет и
// её координат — жест просто не происходит, и смок печатает список
// провалов вместо одного исключения
if (!target || !Number.isFinite(x) || !Number.isFinite(y)) return;
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 11, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
const PITCH = 1000 / 240; // GRID_PITCH — одна клетка сетки
const CELL = 5; // cell_cm демо-пространства (по умолчанию)
const cmToUnits = (cm) => (cm / CELL) * PITCH;
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.cellCmIsFive = c._cellCm === CELL;
// ================= 1. инструмент и палитра ==============================
const toolBtn = [...sr().querySelectorAll('.decorbar .dtool')]
.find((b) => /мебел|furnitur/i.test(b.textContent || ''));
out.furnitureToolInTheBar = !!toolBtn;
out.noPaletteBeforeTheTool = !sr().querySelector('.furnpalette');
if (toolBtn) toolBtn.click();
await c.updateComplete;
out.toolIsSelected = c._decorTool === 'furniture';
const pal = () => sr().querySelector('.furnpalette');
out.paletteOpens = !!pal();
const groups = [...(pal()?.querySelectorAll('.furngroup') || [])]
.map((g) => g.getAttribute('data-group'));
out.groupsArePresent = ['furniture', 'appliance', 'sanitary'].every((g) => groups.includes(g));
out.everyGroupHasSymbols = groups.length > 0 && [...pal().querySelectorAll('.furnrow')]
.every((r) => r.querySelectorAll('.furnitem').length > 0);
const item = (sym) => pal()?.querySelector(`.furnitem[data-symbol="${sym}"]`);
const pick = (sym) => { const b = item(sym); if (b) b.click(); };
out.sofaIsInThePalette = !!item('sofa');
out.toiletIsInThePalette = !!item('toilet');
out.washerIsInThePalette = !!item('washer');
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
out.previewIsDrawn = prevD.length > 10;
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
// ================= 2. размер по умолчанию и поля =========================
pick('sofa'); await c.updateComplete;
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
out.defaultSizeIsTheSymbols = c._furnPalette?.w === 220 && c._furnPalette?.h === 90;
const wIn = () => pal()?.querySelector('input.furnw');
const hIn = () => pal()?.querySelector('input.furnh');
out.sizeFieldsAppear = !!wIn() && !!hIn();
out.fieldsShowMetres = +(wIn()?.value ?? NaN) === 2.2 && +(hIn()?.value ?? NaN) === 0.9;
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
// ================= 3. размещение в реальном размере ======================
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
ev('pointerdown', stageEl(), 300, 300);
await c.updateComplete;
const sofa = c._decorList.find((s) => s.kind === 'furniture');
const sofaId = sofa?.id ?? null;
const sofaNow = () => c._decorList.find((s) => s.id === sofaId) || {};
out.placedOnClick = sofa?.symbol === 'sofa';
out.realWidthThroughCellCm = near((sofa?.w ?? NaN) * 1000, cmToUnits(220), 1e-6);
out.realDepthThroughCellCm = near((sofa?.h ?? NaN) * 1000, cmToUnits(90), 1e-6);
out.centredOnTheClick = !!sofa
&& near((sofa.x + sofa.w / 2) * 1000, 300, 1e-6)
&& near((sofa.y + sofa.h / 2) * 1000, 300, 1e-6);
out.noAngleWhenStraight = !!sofa && sofa.angle === undefined;
out.selectedRightAway = !!sofaId && c._decorSel === sofaId;
out.toolWentBackToSelect = c._decorTool === 'select';
out.paletteDisarmed = c._furnPalette === null;
// …и она НАРИСОВАНА, со всеми хуками card-mod (docs/STYLING-HOOKS.md §3)
out.rendered = el(sofaId)?.tagName?.toLowerCase() === 'path';
out.hookHp = attr(sofaId, 'data-hp') === 'decor';
out.hookKind = attr(sofaId, 'data-kind') === 'furniture';
out.hookSymbol = attr(sofaId, 'data-symbol') === 'sofa';
out.strokedNotFilled = attr(sofaId, 'fill') === 'none'
&& attr(sofaId, 'stroke') === c._decorStyle.color;
out.pathIsAtRealSize = /^M0 0H183\.333V75H0Z/.test(attr(sofaId, 'd') || '');
// ================= 4. свой размер до размещения ==========================
c._decorTool = 'furniture'; await c.updateComplete;
pick('toilet'); await c.updateComplete;
if (wIn()) {
wIn().value = '0.5';
wIn().dispatchEvent(new Event('input', { bubbles: true }));
}
await c.updateComplete;
out.fieldEditsTheArmedSize = Math.abs((c._furnPalette?.w ?? 0) - 50) < 1e-6;
ev('pointerdown', stageEl(), 300, 400);
await c.updateComplete;
const wc = c._decorList.find((s) => s.symbol === 'toilet');
out.typedSizeIsUsed = near((wc?.w ?? NaN) * 1000, cmToUnits(50), 1e-6)
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
// ================= 5. магнит к стене =====================================
c._decorTool = 'furniture'; await c.updateComplete;
pick('bed_double'); await c.updateComplete;
ev('pointerdown', stageEl(), 300, 150); // 10 единиц от стены y=140
await c.updateComplete;
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
const depth = cmToUnits(200);
out.wallMagnetPullsItFlat = !!bed
&& near((bed.y + bed.h / 2) * 1000, 140 + depth / 2, 1e-6);
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
out.wallMagnetQuantisesAlongTheWall = !!bed
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
// …Shift больше не отключает магнит
c._decorTool = 'furniture'; await c.updateComplete;
pick('bed_single'); await c.updateComplete;
ev('pointerdown', stageEl(), 301.7, 151.3, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((s) => s.symbol === 'bed_single');
out.shiftKeepsTheMagnet = !!free
&& !near((free.y + free.h / 2) * 1000, 151.3, 1e-6)
&& free.angle === undefined;
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к левой стене (x=40)
c._decorTool = 'select'; c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
ev('pointermove', stageEl(), 55, 300); // 15 единиц от стены x=40
await c.updateComplete;
const pulled = sofaNow();
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
out.dragMagnetPressesTheBack =
near((pulled.x + pulled.w / 2) * 1000, 40 + cmToUnits(90) / 2, 1e-6);
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
// ================= 6. рамка, углы, плашки, поворот =======================
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.sameFrameAsTheTextBlock = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& frame().querySelectorAll('.dtknob').length === 5
&& !!frame().querySelector('.dtrot');
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
out.frameBoxIsTheShapeBox = !!c._dtBox
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
// угол: НЕзависимые ширина и глубина + живые плашки
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
const se = handles[3] || stageEl(); // SE — четвёртый угол
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
await c.updateComplete;
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
out.twoLivePlates = plates.length === 2;
out.platesShowRealLengths = plates.length === 2
&& plates.every((p) => /\d/.test(p.textContent))
&& plates.some((p) => /m|м|′/.test(p.textContent));
// …и без Shift размер прилипает к сетке
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
const wCells = (snapped.w * 1000) / PITCH;
const hCells = (snapped.h * 1000) / PITCH;
// Normalised config is rounded to six decimals, so a mathematical cell 27
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
&& near(hCells, Math.round(hCells), 1e-4);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
await sleep(40); await c.updateComplete;
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
const piv = c._dtPivot ? c._dtPivot(sofaNow()) : [NaN, NaN];
out.rotatesAboutTheCENTRE = near(piv[0], cx, 1e-6) && near(piv[1], cy, 1e-6);
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
await c.updateComplete;
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
out.snapsTo5 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
const fine = sofaNow().angle;
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
// ================= 7. конфиг и пересборка ================================
const stored = JSON.parse(JSON.stringify(sofaNow()));
out.storedShapeIsComplete = stored.kind === 'furniture' && typeof stored.symbol === 'string'
&& ['x', 'y', 'w', 'h'].every((k) => Number.isFinite(stored[k]));
c.hass = { ...c.hass }; // полная пересборка
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
const after = sofaNow();
out.survivesARebuild = after.symbol === stored.symbol
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
out.stillRenderedAfterRebuild = !!el(sofaId);
c._decorTool = 'select';
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
await c.updateComplete;
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
// ================= 8. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
out.inertUnderTheStamp = pe(sofaId) === 'none';
const n0 = c._decorList.length;
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
await c.updateComplete;
out.noSymbolNoStamp = c._decorList.length === n0;
c._decorTool = 'erase'; await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
await c.updateComplete;
// Eraser is intentionally two-step now; the generic decor smoke verifies
// the dialog itself, while this furniture scenario confirms the action.
c._confirmDecorErase();
await c.updateComplete;
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
// а в режиме просмотра мебель видна и не кликается
c._setMode('view'); await c.updateComplete; await sleep(30);
const left = c._decorList.find((s) => s.kind === 'furniture');
out.visibleInView = !!left && !!el(left.id);
out.inertInView = !!left && pe(left.id) === 'none';
return out;
});
checkAll(res);
await finish(browser, res);
+70 -6
View File
@@ -1,4 +1,4 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
// «+» — навигационное действие, а не инструмент редактора плана
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
const addBtn = () => sr().querySelector('.tab.tabadd');
const hittable = () => {
const b = addBtn();
if (!b) return 'нет кнопки';
const r = b.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
};
out.addInView = hittable();
for (const m of ['plan', 'devices', 'decor']) {
c._setMode(m); await c.updateComplete;
out['addIn_' + m] = hittable();
}
c._setMode('view'); await c.updateComplete;
// …и открывает именно диалог СОЗДАНИЯ
const b0 = addBtn();
if (b0) { b0.click(); await c.updateComplete; }
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
c._spaceDialog = null; await c.updateComplete;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
// программно и всё ещё ловится автотестами как «есть»)
const kio = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__mkHass();
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
document.body.appendChild(c);
await new Promise((r) => setTimeout(r, 350));
c.hass = { ...c.hass }; await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
c.remove();
return out;
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await new Promise((r) => setTimeout(r, 250));
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
})();
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
await finish(browser, { ...res, ...kio, ...narrow });
+6 -6
View File
@@ -8,10 +8,10 @@ const res = await page.evaluate(async () => {
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
out.groups = [...sr().querySelectorAll('hp-dialog .dispsection')].map((l) => l.textContent.trim());
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('.dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('.dialog a.aboutlink')].map((a) => ({
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
@@ -42,9 +42,9 @@ const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 15, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "Grid", "About"],
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
+21 -13
View File
@@ -1,10 +1,12 @@
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; Shift is the only way out;
// 2. the explicit «Выровнять всё по сетке» action moves a deliberately
// detuned plan onto the grid, tells the truth about how much it moves,
// and does nothing at all the second time.
// WALL at a whole number of steps along it; positional off-grid placement
// is not supported. Shift keeps the room-outline point on the grid while
// additionally constraining its wall to the nearest 45° direction;
// 2. the explicit «Оптимизировать планы» action moves a deliberately
// detuned plan onto the grid through one atomic config+layout commit,
// tells the truth about how much it moves, and is idempotent.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
@@ -34,7 +36,7 @@ const out = await page.evaluate(async () => {
const s1 = c._snap([100 + OFF, 200 + OFF]);
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
o.shiftSuspendsTheSnap = !onGridR(s2[0]);
o.shiftCannotSuspendTheSnap = onGridR(s2[0]) && onGridR(s2[1]);
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
// infinite canvas, so no plan's nodes ever moved out from under it
@@ -145,9 +147,16 @@ const out = await page.evaluate(async () => {
c._tool = 'draw'; c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = [[200, 200]];
c._markupClick(at(240 + OFF, 310 + OFF, 'click', { shiftKey: true }));
const locked = c._path[1];
const ldx = Math.abs(locked[0] - 200), ldy = Math.abs(locked[1] - 200);
o.shiftLocksRoomWallTo45 = c._path.length === 2
&& (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7)
&& onGridR(locked[0]) && onGridR(locked[1]);
c._path = []; c._tool = 'draw';
// ---- 2) «Выровнять всё по сетке» --------------------------------------
// ---- 2) «Оптимизировать планы» -----------------------------------------
c._setMode('view'); await c.updateComplete;
// a deliberately detuned fixture: a third of a step off, everywhere
const D = 1 / GRID_N / 3;
@@ -170,11 +179,11 @@ const out = await page.evaluate(async () => {
c._openAlignDialog();
await c.updateComplete;
const dlg = c._alignDialog;
o.alignDialogOpens = !!dlg && !!sr().querySelector('.dialogwrap .alignmsg');
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
o.alignWarnsAboutNoUndo = (sr().querySelector('.dialogwrap .rhint')?.textContent || '').length > 20;
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
// capture what the network is told, then run it
const sent = [];
@@ -183,8 +192,7 @@ const out = await page.evaluate(async () => {
await c._runAlignToGrid();
await c.updateComplete;
o.alignDialogClosed = c._alignDialog === null;
o.alignWroteConfigOnce = sent.filter((t) => t === 'houseplan/config/set').length === 1;
o.alignWroteLayout = sent.some((t) => t === 'houseplan/layout/update');
o.alignUsedAtomicConfigLayoutWrite = sent.filter((t) => t === 'houseplan/plan/optimize').length === 1;
const g = c._serverCfg.spaces[0];
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
@@ -204,7 +212,7 @@ const out = await page.evaluate(async () => {
c._openAlignDialog();
await c.updateComplete;
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
c._alignDialog = null;
await c.updateComplete;
@@ -228,7 +236,7 @@ const out = await page.evaluate(async () => {
const md = c._alignDialog;
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
&& (sr().querySelector('.dialogwrap .body')?.textContent || '').includes('Attic');
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
c._alignDialog = null; await c.updateComplete;
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
@@ -246,7 +254,7 @@ const out = await page.evaluate(async () => {
c._openAlignDialog(); await c.updateComplete;
const ad = c._alignDialog;
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('.dialogwrap .btn.on');
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
await c._runAlignToGrid();
await c.updateComplete;
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
+11 -11
View File
@@ -14,9 +14,9 @@ const res = await page.evaluate(async () => {
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
@@ -35,20 +35,20 @@ const res = await page.evaluate(async () => {
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
out.haNoPlainCheckbox = !sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
await c.updateComplete;
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
out.downstreamChecked = [...sr().querySelectorAll('hp-dialog ha-switch')].some((el) => el.checked === true);
// --- 4) control -> card: change with .checked lands in the dialog ------
const before = c._markerDialog.hideFromPlan;
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
const sw = [...sr().querySelectorAll('hp-dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
@@ -57,7 +57,7 @@ const res = await page.evaluate(async () => {
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
@@ -73,8 +73,8 @@ const res = await page.evaluate(async () => {
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
const gsl = sr().querySelector('.dialog ha-slider');
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
const gsl = sr().querySelector('hp-dialog ha-slider');
gsl.value = 55;
gsl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
+46 -10
View File
@@ -49,11 +49,11 @@ Object.assign(out, await page.evaluate(async () => {
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
// --- авто-устройство тоже можно полностью удалить с плана --------------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
o.deleteForAuto = !!sr().querySelector('hp-dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
@@ -62,13 +62,50 @@ Object.assign(out, await page.evaluate(async () => {
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
// --- у виртуального кнопка «Удалить» есть; удаляется только выбранный ---
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
await new Promise((resolve) => setTimeout(resolve, 2));
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'delete-one-b', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virtualA = c._devices.find((x) => x.virtual && x.name === 'delete-one-a');
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
c._openMarkerDialog(virtualA); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
const savedConfirm = window.confirm;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
// A re-added HA binding replaces its tombstone and receives a fresh layout
// position instead of inheriting the deleted marker's last coordinates.
const readd = c._devices.find((x) => x.bindingKind === 'device' && !x.hidden);
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
c._layout = { ...c._layout, [readd.id]: oldPos };
c._openMarkerDialog(readd); await c.updateComplete;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: `device:${readd.bindingRef}`,
name: readd.name,
};
await c._saveMarker(); await c.updateComplete;
const rebound = (c._serverCfg.markers || [])
.filter((m) => m.binding === `device:${readd.bindingRef}`);
const fresh = c._layout[readd.id];
o.readdReplacesTombstone = rebound.length === 1 && rebound[0].removed !== true;
o.readdUsesFreshPosition = !!fresh
&& (Math.abs(fresh.x - oldPos.x) > 1e-6 || Math.abs(fresh.y - oldPos.y) > 1e-6);
c._markerDialog = null; c._setMode('view');
return o;
}));
@@ -100,7 +137,7 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
@@ -114,11 +151,10 @@ Object.assign(out, await page.evaluate(async () => {
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
+209
View File
@@ -0,0 +1,209 @@
/**
* The two "draw less" space options and the virtual-wall rule (owner
* 2026-08-05), plus the handle beads that came with them.
*
* • «Скрыть декоративный слой» hides decor everywhere EXCEPT its own editor;
* • «Скрыть проёмы» hides doors/windows everywhere EXCEPT the plan editor,
* and does not touch what an opening MEANS (the light still gets through);
* • a space that does not draw room borders draws no dashed virtual walls
* in View; every editor still does, because the complete centreline span
* must remain visible while editing;
* • both switches survive a round trip through the dialog;
* • every corner handle paints a bead a quarter of its hit radius, and the
* hit radius itself is unchanged.
*/
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const set = async (patch) => {
const s = sp();
s.settings = { ...(s.settings || {}), ...patch };
await upd();
};
const mode = async (m) => { c._setMode(m); await upd(); };
const n = (sel) => sr().querySelectorAll(sel).length;
// a shape and an opening to look for, on the space we are on
const s0 = sp();
s0.decor = [{ id: 'hl1', kind: 'rect', x: 0.3, y: 0.3, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
s0.openings = [{ id: 'hl2', type: 'door', x: 0.3, y: 0.2, angle: 0, length: 0.09 }];
await mode('view');
await set({ hide_decor: undefined, hide_openings: undefined, show_borders: true });
// ---- 1) the baseline: both layers are drawn ---------------------------
out.decorDrawnByDefault = n('.decorlayer .dshape') > 0;
out.openingDrawnByDefault = n('[data-hp="opening"]') > 0;
// ---- 2) hide the decor layer ------------------------------------------
await set({ hide_decor: true });
out.decorHiddenInView = n('.decorlayer .dshape') === 0;
await mode('devices');
out.decorHiddenInDevices = n('.decorlayer .dshape') === 0;
// …but its own editor still shows it, or it could not be edited
await mode('decor');
out.decorVisibleInItsEditor = n('.decorlayer .dshape') > 0;
await mode('view');
// the shape itself was never touched
out.decorShapeKept = (sp().decor || []).length === 1;
await set({ hide_decor: undefined });
out.decorBackAfterOff = n('.decorlayer .dshape') > 0;
// ---- 3) hide the openings ---------------------------------------------
const opCount = () => n('[data-hp="opening"]');
const before = opCount();
await set({ hide_openings: true });
out.openingsHiddenInView = opCount() === 0 && before > 0;
await mode('plan');
c._tool = 'opening';
await upd();
out.openingsVisibleInPlanEditor = opCount() > 0;
await mode('view');
// the opening is still an opening: it stayed in the config untouched
out.openingKept = (sp().openings || []).length === 1;
await set({ hide_openings: undefined });
out.openingsBackAfterOff = opCount() === before;
// ---- 4) virtual walls follow the borders switch ------------------------
// an open boundary is a link between two rooms that share a wall; the demo's
// first two rooms do (smoke_render_parity relies on the same fact)
const rooms = sp().rooms || [];
rooms[0].open_to = [rooms[1].id];
await upd();
out.openWallsFound = c._openPairs().length > 0;
await set({ show_borders: true });
out.openWallsDrawnWithBorders = n('.openwalls .openwall') > 0;
await set({ show_borders: false });
out.openWallsGoneWithoutBorders = n('.openwalls .openwall') === 0;
// Every editor still draws them. The dash deliberately reaches the physical
// centreline there, even if show_borders is off; only View hides it.
await mode('plan');
c._tool = 'boundary';
await upd();
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
await mode('devices');
out.openWallsVisibleInDevicesEditor = n('.openwalls .openwall') > 0;
await mode('decor');
out.openWallsVisibleInDecorEditor = n('.openwalls .openwall') > 0;
await mode('view');
await set({ show_borders: true });
// ---- 5) the dialog carries both switches round trip --------------------
c._openSpaceDialog('edit', c._space);
await upd();
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|openings|doors and windows/i.test(t));
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
await c._saveSpaceDialog();
await upd();
out.savedBoth = sp().settings.hide_decor === true && sp().settings.hide_openings === true;
c._openSpaceDialog('edit', c._space);
await upd();
out.dialogReadsOn = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === true;
c._spaceDialog = { ...c._spaceDialog, hideDecor: false, hideOpenings: false };
await c._saveSpaceDialog();
await upd();
// switched off = nothing is stored (undefined, which JSON drops on the way
// to the server), not a stored `false` — the same rule bg_color follows
out.clearedNotStored = sp().settings.hide_decor === undefined
&& sp().settings.hide_openings === undefined
&& !('hide_decor' in JSON.parse(JSON.stringify(sp().settings)));
c._spaceDialog = null;
await upd();
return out;
});
checkAll(res);
// ---- 6) the beads: a quarter of the hit radius, and the hit is unchanged --
const rad = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('decor'); c._decorTool = 'backdrop';
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const hit = sr().querySelector('.bdframe .bdhandle');
const knob = sr().querySelector('.bdframe .bdknob');
const view = c._viewOr(c._baseVb());
const o = {
hasBoth: !!hit && !!knob,
hitR: hit ? Number(hit.getAttribute('r')) : 0,
knobR: knob ? Number(knob.getAttribute('r')) : 0,
expectHit: Math.max(view.w, view.h) * 0.02,
knobInert: knob ? getComputedStyle(knob).pointerEvents === 'none' : false,
// the bead sits exactly on the handle
sameCentre: hit && knob
&& hit.getAttribute('cx') === knob.getAttribute('cx')
&& hit.getAttribute('cy') === knob.getAttribute('cy'),
};
// …and the resize tool's corner beads follow the same rule
c._setMode('plan'); c._tool = 'resize';
c._rszSel = (c._curSpaceCfg.rooms || [])[0]?.id || null;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const rh = sr().querySelector('.rszcorner');
const rk = sr().querySelector('.rszknob');
o.rszBoth = !!rh && !!rk;
o.rszQuarter = rh && rk
? Math.abs(Number(rk.getAttribute('r')) * 4 - Number(rh.getAttribute('r'))) < 0.05
: false;
c._setMode('view');
return o;
});
checkAll({
handleAndBead: rad.hasBoth,
hitRadiusUnchanged: Math.abs(rad.hitR - rad.expectHit) < 0.15,
beadIsAQuarter: Math.abs(rad.knobR * 4 - rad.hitR) < 0.05,
beadTakesNoPointer: rad.knobInert,
beadOnTheHandle: rad.sameCentre,
resizeCornerHasBead: rad.rszBoth,
resizeBeadIsAQuarter: rad.rszQuarter,
});
// ---- 7) every Background session starts on Select; image has its own tool --
const armed = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); await c.updateComplete;
c._setMode('decor'); await c.updateComplete;
const o = {
startsOnSelect: c._decorTool === 'select',
inertUntilPictureTool: !c._bdMovable,
hasPicture: !!c._curSpaceCfg.plan_url,
};
c._decorTool = 'backdrop'; c.requestUpdate(); await c.updateComplete;
o.movable = c._bdMovable;
// and the picture really moves under it: press inside the body, drag, release
const r = c._bdRect;
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
const pt = [r.x + r.w / 2, r.y + r.h / 2];
const ev = (x, y) => ({ pointerId: 42, clientX: x, clientY: y, target: c._stageEl,
preventDefault() {}, stopPropagation() {}, shiftKey: false });
// _svgPoint reads clientX/Y — go through the stage geometry
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const box = stage.getBoundingClientRect();
const vb = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
const toScreen = (x, y) => [box.left + ((x - vb[0]) / vb[2]) * box.width,
box.top + ((y - vb[1]) / vb[3]) * box.height];
const [sx, sy] = toScreen(pt[0], pt[1]);
const [ex, ey] = toScreen(pt[0] + vb[2] * 0.1, pt[1]);
c._stagePointerDown(ev(sx, sy));
c._stagePointerMove(ev(ex, ey));
c._stagePointerUp(ev(ex, ey));
await c.updateComplete;
o.moved = (c._curSpaceCfg.plan_x ?? 0) !== before.x;
c._setMode('view');
return o;
});
checkAll({
backgroundStartsOnSelect: armed.startsOnSelect,
pictureInertUntilOwnTool: armed.inertUntilPictureTool,
pictureMovableUnderOwnTool: armed.movable,
dragMovedThePicture: armed.moved,
});
await finish(browser);
+1 -1
View File
@@ -17,7 +17,7 @@ const res = await page.evaluate(async () => {
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
// пикер (или фолбэк-инпут) получил placeholder
const picker = sr().querySelector('ha-icon-picker');
const inputs = [...sr().querySelectorAll('.dialog input')];
const inputs = [...sr().querySelectorAll('hp-dialog input')];
out.placeholderSet = picker
? picker.placeholder === dev.icon
: inputs.some((i) => i.placeholder === dev.icon);
+1 -1
View File
@@ -159,7 +159,7 @@ Object.assign(out, await page.evaluate(() => {
const sr = c.shadowRoot || c.renderRoot;
const hint = sr.querySelector('.farhint');
o.hintShown = !!hint && !!hint.querySelector('button');
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
o.hintIsNotAModal = !sr.querySelector('hp-dialog');
return o;
}));
+7 -6
View File
@@ -47,12 +47,13 @@ const res = await page.evaluate(async () => {
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
out.evenoddPath = !!evenodd;
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
// 4) остров кликабелен (элемент существует и он поверх дырки)
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
const d = el.getAttribute('points') || el.getAttribute('d') || '';
return d.includes(String(cx)) && el !== evenodd;
});
out.islandRendered = !!islandEl;
// 4) остров кликабелен (элемент существует и он поверх дырки).
// Beta.5+ applies Draw thickness, so the painted contour is inset and no
// longer contains the exact centreline coordinate — look up by room id.
const islandEl = island
? sr().querySelector(`[data-hp="room"][data-id="${island.id}"]`)
: null;
out.islandRendered = !!islandEl && islandEl !== evenodd;
return out;
});
checkAll(res);
+1 -1
View File
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
+7 -11
View File
@@ -1,8 +1,6 @@
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
// Правило владельца (2026-08-06): жёлтая подложка всегда означает «работает
// сейчас». В glow-режиме световое пятно ДОПОЛНЯЕТ её, но не заменяет. RGB
// лампы по-прежнему красит только пятно/эффект, не значок и не рамку.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
@@ -39,21 +37,19 @@ const out = await page.evaluate(async () => {
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
// --- режим glow: и лампа, и розетка жёлтые; пятно остаётся --------------
await setFill('glow');
le = devEl('d_lamp');
const ke = devEl('d_kettle');
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
o.litLampYellowInGlow = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
// в plan-редакторе слоя нет — там класс on должен вернуться)
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
+172
View File
@@ -0,0 +1,172 @@
// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
// текст и несколько переменных; выбор значения вставляет токен в позицию
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** текст фигуры в DOM, склеенный из строк (tspan) */
const dom = (id) => {
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
if (!el) return null;
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
};
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
/** подменить одно состояние — ровно так, как HA присылает новый hass */
const push = async (id, st) => {
const states = { ...c.hass.states };
if (st === null) delete states[id]; else states[id] = st;
c.hass = { ...c.hass, states };
await c.updateComplete;
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
c._curSpaceCfg.decor = [];
// ---------- 1. несколько inline-переменных и обычная надпись -------------
c._decorTextDialog = { x: 0.3, y: 0.3,
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const live = c._decorList.find((x) => x.kind === 'text');
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
out.templateStoredRaw = live.text ===
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const plain = c._decorList.find((x) => x.text === 'Сауна');
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
// ---------- 2. значение живое: новый hass — новый текст -------------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
out.plainStillUntouched = dom(plain.id) === 'Сауна';
// как отдаёт HA: ни округления, ни переформатирования
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
attributes: { unit_of_measurement: '°C' } });
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', null);
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c._decorTextSelection = { start: 3, end: 3 };
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
out.insertedAtCaret = c._decorTextDialog.text ===
'До {sensor.living_temp:linkquality}после';
c._decorSaveText(); await c.updateComplete;
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
out.insertedRenders = dom(inserted.id) === 'До 154после';
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
c._decorTextDialog = { x: 0.55, y: 0.55,
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
out.manualTokensWork = dom(manual.id) === '154 / —';
// ---------- 6. старая связка мигрирует при первом редактировании ----------
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
c._curSpaceCfg.decor = [...c._decorList, legacy];
c.requestUpdate(); await c.updateComplete;
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
open(legacy); await c.updateComplete;
out.legacyOpensAsToken = c._decorTextDialog?.text ===
'Старый {sensor.living_temp}';
c._decorSaveText(); await c.updateComplete;
const migrated = c._decorList.find((x) => x.id === legacy.id);
out.legacyMigrated = !('entity' in migrated)
&& migrated.text === 'Старый {sensor.living_temp}'
&& dom(legacy.id) === 'Старый 22.4 °C';
// Explicit legacy units and attribute names outside the inline grammar are
// not silently destroyed. They stay in the compatible representation until
// the user explicitly replaces the binding.
const legacyUnit = { id: 'legacy_unit', kind: 'text', x: 0.75, y: 0.75,
text: 'Бак {}', color: '#112233', entity: 'sensor.living_temp', unit: 'K' };
c._curSpaceCfg.decor = [...c._decorList, legacyUnit]; c.requestUpdate(); await c.updateComplete;
out.legacyUnitRenders = dom(legacyUnit.id) === 'Бак 22.4 K';
open(legacyUnit); await c.updateComplete;
out.legacyUnitIsNotLossyToken = c._decorTextDialog?.preserveLegacy === true
&& c._decorTextDialog?.text === 'Бак {}';
c._decorSaveText(); await c.updateComplete;
const keptUnit = c._decorList.find((x) => x.id === legacyUnit.id);
out.legacyUnitSurvivesSave = keptUnit?.unit === 'K'
&& keptUnit?.entity === 'sensor.living_temp' && dom(legacyUnit.id) === 'Бак 22.4 K';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154, 'bad attr': 7 } });
const legacyAttr = { id: 'legacy_attr', kind: 'text', x: 0.8, y: 0.8,
text: 'Атрибут {}', color: '#112233', entity: 'sensor.living_temp', attr: 'bad attr' };
c._curSpaceCfg.decor = [...c._decorList, legacyAttr]; c.requestUpdate(); await c.updateComplete;
out.legacyOddAttrRenders = dom(legacyAttr.id) === 'Атрибут 7';
open(legacyAttr); await c.updateComplete;
out.legacyOddAttrPreserved = c._decorTextDialog?.preserveLegacy === true;
c._decorSaveText(); await c.updateComplete;
const keptAttr = c._decorList.find((x) => x.id === legacyAttr.id);
out.legacyOddAttrSurvivesSave = keptAttr?.attr === 'bad attr'
&& keptAttr?.entity === 'sensor.living_temp' && dom(legacyAttr.id) === 'Атрибут 7';
c._decorTextDialog = { x: 0.2, y: 0.2, text: 'x', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c.requestUpdate(); await c.updateComplete;
out.pickerHidesUnrepresentableAttr = ![...sr().querySelectorAll('select.namein option')]
.some((o) => o.value === 'bad attr');
c._decorTextDialog = null;
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
const editorText = dom(live.id);
c._setMode('view'); await c.updateComplete; await sleep(60);
out.sameInViewMode = dom(live.id) === editorText
&& editorText === 'Бак 22.4 °C; LQI 154';
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
kiosk.hass = c.hass;
document.body.appendChild(kiosk);
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
const kr = kiosk.shadowRoot || kiosk.renderRoot;
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
out.sameInKiosk = !!kEl
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
kiosk.remove();
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
const sent = [];
const ws = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
c._setMode('decor'); await c.updateComplete;
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorSaveText();
await sleep(900);
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
.find((x) => x.id === live.id);
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
return out;
});
checkAll(res);
await finish(browser, res);
+30 -1
View File
@@ -61,13 +61,42 @@ out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._areaSel=''; c._saveRoom();});
s = await S();
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
await restore();
// AUD-159B7-01: one open span crossing the Split point belongs to two room
// pairs afterwards. The card transaction must persist both pieces and derive
// light-zone connectivity for both children, not keep only the first half.
const spanSplit = await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((x) => x.id === 'f1');
sp.rooms = [
{ id: 'left_top', name: 'Left top', area: null, poly: [[0, 0], [.5, 0], [.5, .2], [0, .2]] },
{ id: 'left_bottom', name: 'Left bottom', area: null, poly: [[0, .2], [.5, .2], [.5, .4], [0, .4]] },
{ id: 'right', name: 'Right', area: null, poly: [[.5, 0], [1, 0], [1, .4], [.5, .4]] },
];
sp.open_spans = [{ a: [.5, .1], b: [.5, .3] }];
c._commitOpenSpans();
const pieces = (sp.open_spans || []).map((e) =>
[Math.min(e.a[1], e.b[1]), Math.max(e.a[1], e.b[1])]);
const links = Object.fromEntries(sp.rooms.map((r) => [r.id, r.open_to || []]));
return { pieces, links };
});
out.splitKeepsOpenPieces = spanSplit.pieces.length === 2
&& Math.abs(spanSplit.pieces[0][0] - .1) < 1e-9
&& Math.abs(spanSplit.pieces[0][1] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][0] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][1] - .3) < 1e-9;
out.splitSyncsBothSides = spanSplit.links.left_top?.includes('right')
&& spanSplit.links.left_bottom?.includes('right')
&& spanSplit.links.right?.includes('left_top')
&& spanSplit.links.right?.includes('left_bottom');
await restore();
// SPLIT along a wall → refused (both pts on top wall of rg)
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
+3 -1
View File
@@ -12,7 +12,9 @@ const st = () => page.evaluate(() => {
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
// The last bar stays mounted for the exit animation; only an open chrome
// is an active/visible editor bar.
markupBar: !!sr.querySelector('.editorchrome.open .editbar'),
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
+73 -34
View File
@@ -1,11 +1,11 @@
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
// off→on получает activity-event — три последовательные волны
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
// (призрак — конфигурация, не статус).
import { launch, checkAll, finish } from './serve.mjs';
@@ -14,6 +14,24 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// Fresh-card baseline: the marker is built while motion is off and no
// unrelated hass tick is allowed to establish runtime later by accident.
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state: 'off',
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
await c.updateComplete;
c._activityRt.clear();
// Activity is an explicit presentation: opt this fixture into it.
const motionDevice = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = (c._serverCfg.markers || [])
.filter((m) => m.binding !== 'device:' + motionDevice.bindingRef);
c._serverCfg.markers.push({
id: motionDevice.id,
binding: 'device:' + motionDevice.bindingRef,
display: 'icon_ripple',
});
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const setMotion = async (state, device_class) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
@@ -22,19 +40,27 @@ const res = await page.evaluate(async () => {
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
// --- первое событие сразу после build не теряется как baseline ----------
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.senseflash');
out.firstTripAfterBuildFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
await setMotion('off', 'motion');
c._activityRt.clear(); c._syncActivityRuntime(); c.requestUpdate(); await c.updateComplete;
// --- motion off→on: event, 3 волны, подложка НЕ жёлтая -------------------
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.activity-event');
out.motionTripFlashes = !!flashEl;
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
out.flashAnimated = !!after && after.animationName === 'hp-sense';
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
out.flashFinite = waves.length === 3
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
// подложка нейтральная — как у обычного .dev, не как у .dev.on
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
!e.classList.contains('activity-event') && !e.classList.contains('activity-presence')
&& !e.classList.contains('on') && !e.classList.contains('open')
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
@@ -46,76 +72,89 @@ const res = await page.evaluate(async () => {
await sleep(3600);
await c.updateComplete;
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- повторный цикл off→on: новая вспышка --------------------------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс event
// не снимался и animation-name не менялся — браузер продолжал старый
// timeline, и после его конца (base opacity 0) второй trip был невидим.
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
await sleep(1200); // середина первой вспышки (< 3.3s)
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
const a1 = senseAnim(sr().querySelector('.dev.activity-event'));
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.activity-event').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.activity-event'));
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
await sleep(2300);
const el3 = sr().querySelector('.dev.senseflash');
const el3 = sr().querySelector('.dev.activity-event');
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
&& [...el3.querySelectorAll('.activity-ring.event i')]
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
await sleep(1400); await c.updateComplete;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
// --- occupancy: статичное кольцо presence, без анимации -----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.sensehold');
const holdEl = sr().querySelector('.dev.activity-presence');
out.occupancyHolds = !!holdEl;
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
await setMotion('off', 'occupancy');
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
// --- presence аналогично -------------------------------------------------
await setMotion('on', 'presence');
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
// и это именно sense-состояние, не тревога и не «открыто»
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
out.noAlarmNoOpen = !sr().querySelector('.dev.activity-presence.alarm') && !sr().querySelector('.dev.activity-presence.open');
// --- смена effective source синхронно снимает старую вспышку ------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const liveMarker = c._serverCfg.markers.find((m) => m.id === motionDevice.id);
liveMarker.controls = ['switch.kettle'];
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.rebindDropsOldFlashWithoutHassTick = !sr().querySelector('.dev.activity-event');
delete liveMarker.controls;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._serverCfg.markers = (c._serverCfg.markers || [])
.filter((m) => m.binding !== 'device:' + d.bindingRef);
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, display: 'icon_ripple', hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
out.hiddenNotInView = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.dev.ghost');
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
&& !ghost.querySelector('.activity-ring');
return out;
});
checkAll(res, {});
+10 -9
View File
@@ -1,8 +1,8 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// Opening drag rulers (owner 2026-08-03): while a door/window/gate is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
// opt out). The wall is ONE room's edge (owner: "only the wall of one room") —
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
// (center 295), not the old merged 40..960. Real pointer events; the numbers
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
@@ -20,6 +20,7 @@ await page.evaluate(() => {
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
@@ -97,7 +98,7 @@ check('tick_gone_after_drop', await tick(), 0);
check('committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
// ---------- Shift disables the magnet (badges stay) ----------
// ---------- Shift cannot disable the magnet (badges stay) ----
// park the opening off-center first: a drag that returns to within 3 px of its
// own starting point is filtered by the tap threshold, so the Shift approach
// must start away from the center
@@ -111,9 +112,9 @@ await page.keyboard.down('Shift');
await page.mouse.down();
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('no_tick_with_shift', await tick(), 0);
check('tick_with_shift', await tick(), 1);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
check('magnet_with_shift', near(freeX, 0.295, 1e-6));
check('badges_still_there_with_shift', (await badges()).length, 2);
await page.mouse.up();
await page.keyboard.up('Shift');
@@ -238,19 +239,19 @@ const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorA
check('place_ghost_magnetised', near(gh, 295, 1e-6));
}
// Shift opts out of the magnet, the badges stay
// Shift cannot opt out of the magnet, the badges stay
{
await page.keyboard.down('Shift');
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_no_tick_with_shift', await tick(), 0);
check('place_tick_with_shift', await tick(), 1);
check('place_badges_with_shift', (await badges()).length, 2);
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_free_with_shift', !!gh && !near(gh, 295, 1e-6) && near(gh, 293.7, 1));
check('place_ghost_magnetised_with_shift', near(gh, 295, 1e-6));
await page.keyboard.up('Shift');
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Room-coloured tunnels in thick walls: door/window/gate, outer and shared
* walls, a hard split on the wall axis, hide/show policies and editor opacity.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
const update = async () => {
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const wall = (a, b, cm = 20) => ({ key: wallKey(a, b), a, b, cm });
sp.rooms = [
{ id: 'tunnel-left', name: 'Left', area: 'tunnel_left_area',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'light' } },
{ id: 'tunnel-right', name: 'Right', area: 'tunnel_right_area',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: {} },
];
sp.walls = [
wall([0.1, 0.2], [0.5, 0.2]),
wall([0.5, 0.2], [0.5, 0.7]),
wall([0.5, 0.7], [0.9, 0.7]),
];
sp.openings = [
{ id: 'tunnel-window', type: 'window', x: 0.3, y: 0.2, angle: 0, length: 0.1 },
{ id: 'tunnel-door', type: 'door', x: 0.5, y: 0.45, angle: 90, length: 0.1 },
{ id: 'tunnel-gate', type: 'gate', x: 0.7, y: 0.7, angle: 0, length: 0.16 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
fill_colors: {
...(c._serverCfg.settings?.fill_colors || {}),
light_none: { c: '#112233', a: 0.21 },
glow_base: { c: '#445566', a: 0.42 },
},
};
c._setMode('view');
await update();
const tunnels = () => [...root().querySelectorAll('[data-hp="opening-tunnel"]')];
const byId = (id) => root().querySelector(`[data-hp="opening-tunnel"][data-id="${id}"]`);
const out = {};
out.allThreeTypes = tunnels().length === 3;
out.windowRepeatsColdRoom = byId('tunnel-window')?.getAttribute('fill') === '#112233'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-left"]')?.getAttribute('style') || '')
.includes('--room-fill:#112233');
out.gateRepeatsHotRoom = byId('tunnel-gate')?.getAttribute('fill') === '#445566'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-right"]')?.getAttribute('style') || '')
.includes('--room-fill:#445566');
const shared = byId('tunnel-door');
const stops = [...(shared?.querySelectorAll('stop') || [])].map((s) => ({
offset: s.getAttribute('offset'), color: s.getAttribute('stop-color'),
opacity: s.getAttribute('stop-opacity'),
}));
out.sharedUsesOneHardGradient = !!shared?.querySelector('linearGradient') && stops.length === 4;
out.sharedStopsOnWallAxis = stops[1]?.offset === '50.000000%'
&& stops[2]?.offset === '50.000000%';
out.sharedCarriesBothRooms = new Set(stops.map((s) => s.color)).has('#112233')
&& new Set(stops.map((s) => s.color)).has('#445566');
const svgEl = root().querySelector('.stage svg');
const tunnelGroup = root().querySelector('.opening-tunnels');
const wallGroup = root().querySelector('.wallbodies');
out.layerBeforeWall = !!svgEl && !!tunnelGroup && !!wallGroup
&& !!(tunnelGroup.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
sp.settings.hide_openings = true;
await update();
out.hideSymbolKeepsTunnel = root().querySelectorAll('[data-hp="opening"]').length === 0
&& tunnels().length === 3;
sp.settings.show_borders = false;
await update();
out.hiddenBordersKeepTunnel = root().querySelectorAll('.wallbodies').length === 0
&& tunnels().length === 3;
c._setMode('decor');
await update();
out.backdropUsesSingleGroupOpacity = tunnels().length === 3
&& getComputedStyle(root().querySelector('.opening-tunnels')).opacity === '0.35'
&& byId('tunnel-window')?.getAttribute('fill-opacity') === '0.21';
c._setMode('plan');
await update();
out.planGestureCannotLeaveStalePatch = tunnels().length === 0;
return out;
});
checkAll(result);
await finish(browser, result);
+33 -21
View File
@@ -4,18 +4,22 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000; // square canvas
c._openWallClick([550, 0.25 * H]);
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
// Shared wall r1|r2 at x=550, y from 140..460. Two-click open full shared stretch.
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
// пунктир отрисован
out.hasSpans = Array.isArray(c._curSpaceCfg.open_spans) && c._curSpaceCfg.open_spans.length > 0;
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
const boundaryButtons = [...sr().querySelectorAll('.editbar button')]
.filter((b) => /Boundary|Граница/.test(b.textContent || ''));
out.oneBoundaryTool = boundaryButtons.length === 1;
out.boundaryToolUsesReviewedIcon = boundaryButtons[0]?.querySelector('ha-icon')
?.getAttribute('icon') === 'mdi:border-style';
// View: trimmed outlines + dash
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
@@ -24,30 +28,28 @@ const res = await page.evaluate(async () => {
const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi;
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
const midY = 0.25 * H;
const midY = 300;
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._tool = 'boundary';
c._boundaryClick([550, 300]); await c.updateComplete;
out.restoredBySameTool = !(c._curSpaceCfg.open_spans || []).length;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
// клик мимо стен — тост, без изменений
c._openWallClick([100, 100]);
c._boundaryClick([100, 100]);
out.missToast = !!c._toast;
// снова открыть для glow-теста
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
// reopen for glow
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
@@ -57,17 +59,27 @@ const res = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
c._setMode('plan'); c._tool = 'boundary';
// shared r2|r3 at y=460, x 550..960
c._boundaryClick([700, 460]);
c._boundaryClick([900, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
// outer wall refuses open
c._setMode('plan'); c._tool = 'boundary';
c._toast = null;
c._boundaryClick([40, 300]);
out.outerToast = !!(c._toast && String(c._toast).length);
return out;
});
checkAll(res);
checkAll(res, { outerToast: true });
await finish(browser, res);
+80 -21
View File
@@ -5,31 +5,90 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000; // square canvas
// 1) без наведения: курсор default, превью нет
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
const H = 1000;
// A pure touch tap has no hover/pointermove. P1 must still be visible, and a
// pen uses the same coarse tolerances as touch.
c._boundaryPointerType = 'touch';
c._cursorPt = null;
c._boundaryClick([550, 200]); await c.updateComplete;
out.touchTapShowsAnchor = !!c._openWallAnchor && !!sr().querySelector('.boundary-point');
c._openWallAnchor = null;
c._boundaryPointerType = 'pen';
out.penUsesCoarseTolerance = Math.abs(c._boundaryTolerances().hit - c._cssPxToRender(22)) < 1e-6;
c._boundaryPointerType = 'mouse';
// The semantic hit width remains exactly 12 CSS px at both view scales.
const sp = c._curSpaceCfg;
const originalWalls = sp.walls;
delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
const originalView = c._view ? { ...c._view } : null;
const baseView = c._viewOr(c._baseVb());
const atCssOffset = (px) => c._boundaryTargetAt([
550 + px * c._cssPxToRender(1), 300,
]).kind;
const fineAtBase = [atCssOffset(11), atCssOffset(13)];
c._view = {
x: baseView.x + baseView.w / 4, y: baseView.y + baseView.h / 4,
w: baseView.w / 2, h: baseView.h / 2,
};
const fineAtZoom = [atCssOffset(11), atCssOffset(13)];
out.cssPixelToleranceSurvivesZoom = fineAtBase[0] === 'shared' && fineAtBase[1] === 'none'
&& fineAtZoom[0] === 'shared' && fineAtZoom[1] === 'none';
c._view = originalView;
if (originalWalls) sp.walls = originalWalls;
else delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
// Independent masonry is neutral away from room boundaries, but blocks a
// shared boundary directly below it.
const originalPartitions = sp.partitions;
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-center-partition', a: [0.2, 0.3], b: [0.4, 0.3], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.barePartitionIsNeutral = c._boundaryTargetAt([300, 300]).kind === 'none';
c._toast = '';
c._boundaryClick([300, 300]);
out.barePartitionUsesBoundaryMessage = c._toast === c._t('toast.openwall_pick');
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-boundary-partition', a: [0.55, 0.14], b: [0.55, 0.46], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.partitionAboveBoundaryBlocks = c._boundaryTargetAt([550, 300]).kind === 'blocked';
if (originalPartitions) sp.partitions = originalPartitions;
else delete sp.partitions;
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
// 1) idle: neutral until a semantic target is under the pointer
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
// 2) solid shared wall: local anchor marker, never a full-edge preview
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
const prev = sr().querySelectorAll('.openwall-preview');
out.previewShown = prev.length > 0;
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
// превью лежит на стене x=550
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
// 3) наведение в центр комнаты: снова default, превью исчезло
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
out.missCursor = getComputedStyle(stage()).cursor === 'default';
out.missNoPreview = !sr().querySelector('.openwall-preview');
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
const prev2 = sr().querySelector('.openwall-preview');
out.willclose = prev2?.classList.contains('willclose') === true;
// прибрать
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.hoverLocalMarker = !!sr().querySelector('.boundary-point') && !sr().querySelector('.openwall-preview');
out.hoverCursor = getComputedStyle(stage()).cursor === 'crosshair';
// 3) The same tool predicts the full body that one click will restore.
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._cursorPt = [550, 300]; c.requestUpdate(); await c.updateComplete;
const prev = sr().querySelector('.openwall-preview');
out.sameToolWillRestore = prev?.classList.contains('boundary-restore') === true;
out.restoreCursor = getComputedStyle(stage()).cursor === 'pointer';
const planClosedOpenSpan = c._planClosedOpenSpan.bind(c);
let previewPlans = 0;
c._planClosedOpenSpan = (...args) => { previewPlans++; return planClosedOpenSpan(...args); };
c._boundaryPreviewMemo = null;
void c._boundaryPreview; void c._boundaryPreview; void c._boundaryPreview;
c._planClosedOpenSpan = planClosedOpenSpan;
out.restorePreviewIsMemoized = previewPlans === 1;
// 4) with anchor set: rubber-band preview appears
c._boundaryClick([550, 300]);
await c.updateComplete;
c._boundaryClick([550, 300]);
out.samePlaceSecondTapDoesNotRearm = !c._openWallAnchor;
c._boundaryClick([550, 200]);
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
const band = sr().querySelector('.openwall-preview');
out.anchorPreview = !!band && band.classList.contains('boundary-range');
c._openWallAnchor = null;
return out;
});
checkAll(res);
+1 -1
View File
@@ -49,7 +49,7 @@ Object.assign(out, await page.evaluate(async () => {
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'delroom']) {
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'boundary', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
+106
View File
@@ -0,0 +1,106 @@
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
// смотрел только на DOM и пропустил его.
import { launch, checkAll, finish } from './serve.mjs';
const VIEWPORTS = [
{ name: 'desktop', width: 900, height: 1000 },
{ name: 'mobile', width: 390, height: 780 },
];
const out = {};
for (const vp of VIEWPORTS) {
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
const res = await page.evaluate(async (mode) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
const mk = (i) => ({
name: `plan${i}.png`,
url: `/api/houseplan/content/plans/_/plan${i}.png`,
size: 100000 + i * 1000,
modified: i,
used_by: [],
});
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
}
return base(m);
} };
const openPicker = async () => {
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
await c.updateComplete;
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r()));
};
const geom = (tag) => {
const box = sr().querySelector('.savedplans');
const rows = [...sr().querySelectorAll('.savedplan')];
o[tag + 'Rows'] = rows.length;
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
const b = box.getBoundingClientRect();
const r0 = rows[0].getBoundingClientRect();
o[tag + 'BoxH'] = Math.round(b.height);
// 2-3 ряда миниатюр: ряд ~40px + отступы
o[tag + 'BoxTall'] = b.height >= 100;
// первая миниатюра целиком внутри контейнера и не нулевая
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
// при 6 планах список не влезает целиком — должна быть прокрутка
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
box.scrollTop = 9999;
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
box.scrollTop = 0;
};
// 1. диалог создания пространства
c._openSpaceDialog('create');
await c.updateComplete;
await openPicker();
geom('create');
// 2. диалог настроек существующего пространства
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
await openPicker();
geom('edit');
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
serverPlans = [];
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
await c.updateComplete;
await openPicker();
const empty = sr().querySelector('.savedplans');
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
o.mode = mode;
return o;
}, vp.name);
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
await browser.close();
}
console.log(JSON.stringify(out, null, 1));
checkAll(out, {
'desktop.createRows': 6,
'desktop.editRows': 6,
'desktop.createBoxH': out['desktop.createBoxH'],
'desktop.editBoxH': out['desktop.editBoxH'],
'desktop.mode': 'desktop',
'mobile.createRows': 6,
'mobile.editRows': 6,
'mobile.createBoxH': out['mobile.createBoxH'],
'mobile.editBoxH': out['mobile.editBoxH'],
'mobile.mode': 'mobile',
});
await finish(null);
+17
View File
@@ -48,6 +48,23 @@ const rec = await page.evaluate(async () => {
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A detach during resume/nav motion kills their timers. The transient state
// must die with the timers, otherwise the same element comes back veiled or
// permanently clipped by nav-enter.
c._resumeSettling = true;
c._navMotion = 'enter';
c._slide = 'left';
c.requestUpdate(); await c.updateComplete;
c.remove();
out.detachClearsResumeAndNav = c._resumeSettling === false
&& c._navMotion === '' && c._slide === '';
wrap.appendChild(c);
await c.updateComplete;
const stageAfterMotionReattach = sr().querySelector('.stage');
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
&& !stageAfterMotionReattach.classList.contains('hpnav')
&& !stageAfterMotionReattach.classList.contains('hpresume');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
+10 -11
View File
@@ -3,38 +3,37 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// счётчики вызовов тяжёлой геометрии
// AUD-159B6-06: the old hook `_computeOpenPairs` is gone. The observable
// contract that still matters is the MODEL memo — a hass tick that changes
// no geometry must not rebuild spaces (audit L1). Open-pair work is cheap
// and may run once per paint; we only refuse a storm (>2× renders).
let pairsCalls = 0, buildCalls = 0;
const origPairs = c._computeOpenPairs.bind(c);
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
const origPairs = c._openPairs.bind(c);
c._openPairs = (...a) => { pairsCalls++; return origPairs(...a); };
const origBuild = c._buildModel.bind(c);
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
// открыть границу, чтобы pairs было что считать
const sp = c._curSpaceCfg;
const r1 = sp.rooms[0], r2 = sp.rooms[1];
r1.open_to = [r2.id]; r2.open_to = [r1.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
pairsCalls = 0; buildCalls = 0;
// 10 «пушей состояния» от HA без изменения конфига
for (let i = 0; i < 10; i++) {
c.hass = { ...c.hass, states: { ...c.hass.states } };
await c.updateComplete;
}
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
out.pairsBounded = pairsCalls <= 20;
out.modelBuildsPer10Renders = buildCalls;
// правка конфига обязана инвалидировать кэш
const before = pairsCalls;
const r3 = sp.rooms[2] || sp.rooms[0];
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.invalidatesOnEdit = pairsCalls > before;
// геометрия по-прежнему правильная: пунктир на месте
sp.settings = { ...(sp.settings || {}), show_borders: true };
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"pairsPerRender": 0,
"modelBuildsPer10Renders": 0,
modelBuildsPer10Renders: 0,
});
await finish(browser, res);
+14 -9
View File
@@ -15,17 +15,22 @@ 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._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
c._rszSel = null; c._rszDrag = null; c._rszLive = null;
c._geometryHistory.clear();
if ('_rszPreview' in c) c._rszPreview = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
@@ -138,7 +143,7 @@ await settle();
await settle();
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
check('03_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
await page.waitForTimeout(800);
check('03_no_pending_write', (await writes()) - w0, 0);
@@ -166,7 +171,7 @@ await settle();
});
await settle();
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
check('03_corner_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
await page.waitForTimeout(800);
check('03_corner_no_write', (await writes()) - w0, 0);
await page.mouse.up();
+196
View File
@@ -0,0 +1,196 @@
/**
* Regression: resize a shared wall whose middle is virtual and whose solid
* remainders carry thickness. The live overlay, commit and Undo must move one
* geometry transaction. Virtual drawing paints above the real wall body in
* editors, but below it in View so thick-wall jambs mask the dash ends.
*/
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 sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
sp().settings = { ...(sp().settings || {}), show_borders: true };
delete sp().walls;
delete sp().open_spans;
delete sp().openings;
for (const r of sp().rooms || []) delete r.open_to;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
}
await upd();
out.realBodyReady = !!sr().querySelector('.wallbodies');
// The two-click rubber band used to be below .wallbodies and disappeared
// exactly where it crossed the real T receiver.
c._tool = 'boundary';
c._boundaryClick([550, 200]);
c._cursorPt = [550, 300];
await upd();
const bodyGroup = sr().querySelector('.wallbodies');
const virtualGroup = sr().querySelector('.openwalls');
out.virtualPreviewDrawn = !!sr().querySelector('.openwall-preview');
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
c._boundaryClick([550, 300]);
c._cursorPt = null;
await upd();
out.partialSpanCreated = (sp().open_spans || []).length === 1;
const editorBody = sr().querySelector('.wallbodies');
const editorVirtual = sr().querySelector('.openwalls');
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('view');
await upd();
const viewBody = sr().querySelector('.wallbodies');
const viewVirtual = sr().querySelector('.openwalls');
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('decor');
await upd();
const decorBody = sr().querySelector('.wallbodies');
const decorVirtual = sr().querySelector('.openwalls');
out.realWallsFadeBehindDecor = !!decorBody
&& getComputedStyle(decorBody).opacity === '0.35';
out.virtualWallsFadeBehindDecor = !!decorVirtual
&& getComputedStyle(decorVirtual).opacity === '0.35';
c._setMode('plan');
await upd();
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
&& c._intervalCm([550, 205, 550, 295]) === 0
&& c._intervalCm([550, 305, 550, 455]) === 20;
const before = JSON.stringify({
rooms: sp().rooms,
walls: sp().walls,
open_spans: sp().open_spans,
});
c._tool = 'resize';
await upd();
const stage = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
const r = stage().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 pev = (type, target, x, y) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: 77, clientX, clientY, button: 0, isPrimary: true,
}));
};
const handle = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')].find((h) => {
const x = +h.getAttribute('cx'), y = +h.getAttribute('cy');
return Math.abs(x - 550) < c._gridPitch * 2 && y > 140 && y < 460;
});
out.resizeHandleFound = !!handle;
if (handle) {
const x = +handle.getAttribute('cx');
const y = +handle.getAttribute('cy');
const step = c._gridPitch;
pev('pointerdown', handle, x, y);
pev('pointermove', handle, x + step, y);
await c.updateComplete;
const liveCut = c._openCuts()[0];
const liveX = liveCut?.[0];
out.liveSpanMoves = Number.isFinite(liveX) && Math.abs(liveX - (550 + step)) < 0.1;
out.liveThicknessStays = c._intervalCm([liveX, 145, liveX, 195]) === 20
&& c._intervalCm([liveX, 205, liveX, 295]) === 0
&& c._intervalCm([liveX, 305, liveX, 455]) === 20;
out.liveBodyStays = !!sr().querySelector('.wallbodies');
pev('pointerup', handle, x + step, y);
await upd();
const finalCut = c._openCuts()[0];
const finalX = finalCut?.[0];
out.commitSpanMoves = Number.isFinite(finalX) && Math.abs(finalX - (550 + step)) < 0.1;
out.commitThicknessStays = c._intervalCm([finalX, 145, finalX, 195]) === 20
&& c._intervalCm([finalX, 205, finalX, 295]) === 0
&& c._intervalCm([finalX, 305, finalX, 455]) === 20;
out.commitKeepsAtomicKeys = (sp().walls || []).filter((w) => w.cm === 20).length >= 2;
c._undoGeometry();
await upd();
out.undoRestoresAll = JSON.stringify({
rooms: sp().rooms,
walls: sp().walls,
open_spans: sp().open_spans,
}) === before;
}
// Canonical storage: legacy/transactional fragments on one room pair become
// one selectable span, and closing it restores the uniformly thick parent
// wall instead of leaving atomic debris in `walls`.
const restoredCut = c._openCuts()[0];
if (restoredCut) {
const mx = (restoredCut[0] + restoredCut[2]) / 2;
const my = (restoredCut[1] + restoredCut[3]) / 2;
c._persistOpenCuts([
[restoredCut[0], restoredCut[1], mx, my],
[mx, my, restoredCut[2], restoredCut[3]],
]);
await upd();
out.adjacentSpansPersistAsOne = (sp().open_spans || []).length === 1
&& c._openCuts().length === 1;
c._closeOpenSpan(c._openCuts()[0]);
await upd();
out.closeRejoinsUniformWall = !sp().open_spans
&& (sp().walls || []).length === 1
&& c._intervalCm(restoredCut) === 20;
} else {
out.adjacentSpansPersistAsOne = false;
out.closeRejoinsUniformWall = false;
}
// The screenshot topology: the two real arms belong to different room
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
// per-room butt caps must be completed into one clean outer mitre.
sp().rooms = [
{ id: 'ta', name: 'A', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
];
delete sp().walls;
delete sp().open_spans;
c._tool = 'wallthick';
await upd();
for (const p of [[300, 500], [500, 700]]) {
c._wallThickClick(p);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
await upd();
}
}
sp().open_spans = [{ a: [0.5, 0.5], b: [0.9, 0.5] }];
sp().rooms.find((r) => r.id === 'tb').open_to = ['tc'];
sp().rooms.find((r) => r.id === 'tc').open_to = ['tb'];
await upd();
const tPath = sr().querySelector('.wallbody')?.getAttribute('d') || '';
const values = (tPath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
const half = (20 / c._cellCm) * c._gridPitch / 2;
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
&& Math.abs(x - (500 + half)) < 1e-6
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
return out;
});
checkAll(res);
await finish(browser, res);
+2 -2
View File
@@ -9,10 +9,10 @@ const res = await page.evaluate(async () => {
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
await c.updateComplete;
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
// живёт только в glow-пятне и в фолбэке цвета пульсации
// живёт только в glow-пятне и в фолбэке цвета activity-эффекта
out.rgbClassGone = !sr().querySelector('.dev.rgb');
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
// фолбэк activity сохраняет цвет свечения: маркер icon_ripple на лампе
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
+12 -7
View File
@@ -8,17 +8,22 @@ 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._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null;
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszPreview = null;
c._geometryHistory.clear();
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
const roomPolyN = (id) => page.evaluate((id) => {
+2 -2
View File
@@ -43,8 +43,8 @@ const res = await page.evaluate(async () => {
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
out.createHasSection = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('hp-dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
+2 -2
View File
@@ -35,7 +35,7 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
out.saveBlockedWithoutPlan = !!sr().querySelector('hp-dialog .btn.on[disabled]');
// открываем список сохранённых
await c._toggleServerPlans();
@@ -54,7 +54,7 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
out.listClosed = !c._spaceDialog.pickSaved;
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabledAfterPick = !sr().querySelector('hp-dialog .btn.on[disabled]');
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
// на несуществующий файл (HP-1470-02)
+1 -1
View File
@@ -70,7 +70,7 @@ const out3 = await page.evaluate(async () => {
const dev = c._devices.find((d) => d.space === 'f1');
c._openMarkerDialog(dev); await c.updateComplete;
// строка «размер · угол»: два ползунка, второй — угол
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
const rows = [...sr().querySelectorAll('hp-dialog .colorrow')];
const row = rows.find((r) => r.textContent.includes('°'));
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
+7 -2
View File
@@ -1,4 +1,4 @@
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
// Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
@@ -18,6 +18,7 @@ const res = await page.evaluate(async () => {
const stage = card.renderRoot.querySelector('.hp-static-stage');
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
const litMarker = card.renderRoot.querySelector('.hp-static-stage .dev[data-id="d_light1"]');
const btn = card.renderRoot.querySelector('.hp-static-btn');
// deep-link: clicking the button pushes #space=<id>
@@ -35,6 +36,8 @@ const res = await page.evaluate(async () => {
return {
stagePointerEvents: pe,
markers,
litMarkerOn: !!litMarker?.classList.contains('on'),
sharedFacePresent: !!litMarker?.querySelector('ha-icon'),
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
@@ -45,9 +48,11 @@ await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
res.markers > 0 &&
res.litMarkerOn &&
res.sharedFacePresent &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: static (pointer-events:none), markers rendered, deep-link button, error card');
console.log('OK space-card: live shared marker face, pointer-events:none, deep-link button, error card');
+2 -1
View File
@@ -39,12 +39,13 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
out.atticOpensPlanEditor = c._space === attic?.id && c._mode === 'plan' && c._tool === 'draw';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+72
View File
@@ -0,0 +1,72 @@
// Always-static display contract: one neutral face across live states, while
// the existing dynamic display keeps reacting. Run only in the prerelease gate.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(() => {
const card = window.__card;
const saved = card.hass;
const show = (device, states) => {
card.hass = {
...saved,
entities: {
...saved.entities,
...Object.fromEntries(Object.keys(states).map((entity_id) => [entity_id, {
entity_id, device_id: device.id, platform: 'demo', disabled_by: null,
}])),
},
states: { ...saved.states, ...states },
};
const presentation = card._devicePresentation(device);
card.hass = saved;
return {
classes: presentation.classes,
icon: presentation.icon,
status: presentation.visual.status,
activity: presentation.activity,
temp: presentation.tempText,
lqi: presentation.lqiText,
vacuumLive: presentation.vacuumLive,
};
};
const smoke = {
id: 'static-smoke', name: 'Smoke', icon: 'mdi:smoke-detector',
entities: ['binary_sensor.static_smoke'], primary: 'binary_sensor.static_smoke',
marker: {
binding: 'device:static-smoke', display: 'static_icon',
vacuum: { live: true },
},
};
const relay = {
// Reuse the harness registry entry: `_planHass` deliberately resolves
// against the acquired HA registry, so an invented entity would correctly
// be absent even if it were injected into `hass.states` for this frame.
id: 'd_kettle', name: 'Relay', icon: 'mdi:toggle-switch',
entities: ['switch.kettle'], primary: 'switch.kettle',
marker: { binding: 'device:d_kettle', display: 'badge' },
};
const smokeOn = show(smoke, {
'binary_sensor.static_smoke': {
state: 'on', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const smokeUnavailable = show(smoke, {
'binary_sensor.static_smoke': {
state: 'unavailable', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const relayOn = show(relay, {
'switch.kettle': { state: 'on', attributes: {} },
});
return {
staticAlarmNeutral: smokeOn.status === 'neutral'
&& smokeOn.activity === 'none'
&& smokeOn.classes.join(' ') === 'static-icon',
staticUnavailableSame: JSON.stringify(smokeOn) === JSON.stringify(smokeUnavailable),
staticSuppressesVacuum: smokeOn.vacuumLive === false,
liveSwitchStillDynamic: relayOn.status === 'working' && relayOn.classes.includes('on'),
};
});
await finish(browser, checkAll(out));
+191
View File
@@ -0,0 +1,191 @@
// Хуки для card-mod (docs/STYLING-HOOKS.md): на каждом значимом объекте плана
// есть стабильные data-атрибуты, и по документированным селекторам элементы
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
// у комнаты без area нет data-area (никаких "undefined" в DOM);
// 8) те же хуки в статичной карточке houseplan-space-card.
// ПАДАЕТ на сборке до этих правок: data-hp нет вообще, а data-id есть только
// у текстовой надписи декора.
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 q = (sel) => sr().querySelector(sel);
const qa = (sel) => [...sr().querySelectorAll(sel)];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ============ 1. устройства ============
const devs = qa('[data-hp="device"]');
out.deviceHookFindsEveryMarker = devs.length > 0 && devs.length === qa('.dev').length;
out.deviceKeepsItsClass = devs.every((e) => e.classList.contains('dev'));
out.deviceHasId = devs.every((e) => !!e.getAttribute('data-id'));
// id — тот же, что у устройства в модели карточки, а не порядковый номер в DOM
const modelIds = new Set(c._devices.filter((d) => d.space === c._space && !d.hidden).map((d) => d.id));
out.deviceIdIsTheConfigId = devs.every((e) => modelIds.has(e.getAttribute('data-id')));
// селектор из документации находит конкретное устройство по СУЩНОСТИ
const lock = q('[data-hp="device"][data-entity="lock.front_door"]');
out.deviceFoundByEntity = !!lock;
out.deviceEntityMatchesPrimary = !!lock
&& c._devices.find((d) => d.id === lock.getAttribute('data-id'))?.primary === 'lock.front_door';
out.deviceHasArea = lock?.getAttribute('data-area') === 'hallway';
// и по ЗОНЕ: все маркеры кухни разом
out.devicesFoundByArea = qa('[data-hp="device"][data-area="kitchen"]').length > 0;
out.noUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 2. комнаты ============
const rooms = qa('[data-hp="room"]');
out.roomHookFindsEveryRoom = rooms.length === c._curSpaceCfg.rooms.length;
out.roomKeepsItsClass = rooms.every((e) => e.classList.contains('room'));
const kitchen = q('[data-hp="room"][data-area="kitchen"]');
out.roomFoundByArea = !!kitchen && kitchen.getAttribute('data-id') === 'r2';
out.roomFoundById = !!q('[data-hp="room"][data-id="r3"]');
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
// ============ 3. подписи комнат ============
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
const wasNames = disp.show_names;
const planUrl = c._curSpaceCfg.plan_url;
const redraw = async () => {
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
};
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
out.svgRoomLabelHooked = svgLabels.length > 0
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
out.htmlRoomLabelHooked = !!htmlLabel;
out.htmlRoomLabelHasArea = htmlLabel?.getAttribute('data-area') === 'kitchen';
disp.show_names = wasNames; await redraw();
// ============ 4. проёмы ============
c._curSpaceCfg.openings = [
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
// bottom wall of r1: the room is above it, so an exterior gate must open
// toward +Y. The two compact leaves therefore rotate +10° / -10°.
{ id: 'op_g', type: 'gate', x: 0.3, y: 0.58, angle: 0, length: 0.3 },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.openingHookFindsEveryType = qa('[data-hp="opening"]').length === 3;
const door = q('[data-hp="opening"][data-kind="door"]');
const win = q('[data-hp="opening"][data-kind="window"]');
const gate = q('[data-hp="opening"][data-kind="gate"]');
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
out.openingKindGate = gate?.getAttribute('data-id') === 'op_g';
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
const gateLeaves = [...(gate?.querySelectorAll('.op-leaf') || [])];
out.gateHasTwoLeaves = gateLeaves.length === 2;
out.gateOpensOnlyTenDegreesOutward = gateLeaves[0]?.getAttribute('style')?.includes('rotate(10deg)')
&& gateLeaves[1]?.getAttribute('style')?.includes('rotate(-10deg)')
&& !gate?.querySelector('.op-arc');
// ============ 5. декор ============
c._curSpaceCfg.decor = [
{ id: 'dc_l', kind: 'line', x1: 0.1, y1: 0.9, x2: 0.4, y2: 0.9, color: '#123456', width: 3 },
{ id: 'dc_r', kind: 'rect', x: 0.1, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_e', kind: 'ellipse', x: 0.3, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_t', kind: 'text', x: 0.5, y: 0.92, text: 'Подпись', color: '#123456' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.decorHookFindsEveryShape = qa('[data-hp="decor"]').length === 4;
out.decorKindLine = q('[data-hp="decor"][data-kind="line"]')?.getAttribute('data-id') === 'dc_l';
out.decorKindRect = q('[data-hp="decor"][data-kind="rect"]')?.getAttribute('data-id') === 'dc_r';
out.decorKindEllipse = q('[data-hp="decor"][data-kind="ellipse"]')?.getAttribute('data-id') === 'dc_e';
out.decorKindText = q('[data-hp="decor"][data-kind="text"]')?.getAttribute('data-id') === 'dc_t';
out.decorKeepsItsClasses = qa('[data-hp="decor"]').length === 4
&& qa('[data-hp="decor"]').every((e) => e.classList.contains('dshape'))
&& !!q('[data-hp="decor"][data-kind="text"]')?.classList.contains('dtext');
// старый селектор текстовой надписи (его уже использует smoke_decor_text) цел
out.legacyDtextSelectorStillWorks = !!q('text.dtext[data-id="dc_t"]');
// ============ 6. вкладки пространств ============
const tabs = qa('[data-hp="space-tab"]');
out.spaceTabHookFindsEveryFloor = tabs.length === c._model.length;
out.spaceTabHasId = tabs.map((e) => e.getAttribute('data-id')).join(',') === c._model.map((s) => s.id).join(',');
out.spaceTabKeepsItsClass = tabs.every((e) => e.classList.contains('tab'));
// кнопка «+» вкладкой пространства не является
out.addButtonIsNotASpaceTab = !q('.tabadd[data-hp]');
// ============ 7. «нет значения — нет атрибута» ============
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_virt', binding: 'virtual', space: c._space, name: 'Вентиль' },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const virt = q('[data-hp="device"][data-id="m_virt"]');
out.virtualMarkerRendered = !!virt;
out.virtualHasNoEntityAttr = !!virt && !virt.hasAttribute('data-entity');
out.virtualHasNoUndefinedText = !!virt && virt.getAttribute('data-entity') === null;
// комната без HA-области (подкомната): data-hp есть, data-area нет.
// Она рисуется только когда включены границы — это её обычное условие показа.
c._curSpaceCfg.rooms = [...c._curSpaceCfg.rooms,
{ id: 'r_sub', name: 'Кладовка', area: null, poly: [[0.06, 0.6], [0.2, 0.6], [0.2, 0.7], [0.06, 0.7]] }];
disp.show_borders = true;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const sub = q('[data-hp="room"][data-id="r_sub"]');
out.roomWithoutAreaRendered = !!sub;
out.roomWithoutAreaHasNoAreaAttr = !!sub && !sub.hasAttribute('data-area');
out.stillNoUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 8. ограничение shadow DOM (docs/STYLING-HOOKS.md §5) =========
// хост ha-icon доступен селектору, его внутренности — нет: именно поэтому
// хуки висят на НАШИХ обёртках
const anyIcon = q('[data-hp="device"] ha-icon');
out.iconHostReachable = !!anyIcon;
out.iconInternalsAreBehindItsOwnRoot = !!anyIcon && !!anyIcon.shadowRoot
&& !sr().querySelector('[data-hp="device"] ha-icon svg');
return out;
});
checkAll(res, { iconInternalsAreBehindItsOwnRoot: !!res.iconInternalsAreBehindItsOwnRoot });
// ============ 9. те же хуки в статичной карточке ============
const stat = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
// своя копия конфига с включёнными именами: у статичной карточки подпись
// комнаты — это HTML-плашка, и она должна нести тот же хук
const base = window.__card.hass;
const cfg = JSON.parse(JSON.stringify(window.__card._serverCfg));
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), show_names: true };
el.hass = { ...base, callWS: async (m) => (m.type === 'houseplan/config/get'
? { config: cfg, rev: 9 } : base.callWS(m)) };
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 rr = el.renderRoot;
out.staticRoomsHooked = rr.querySelectorAll('[data-hp="room"]').length > 0;
out.staticRoomFoundByArea = !!rr.querySelector('[data-hp="room"][data-area="kitchen"]');
out.staticRoomFoundById = !!rr.querySelector('[data-hp="room"][data-id="r1"]');
out.staticDevicesHooked = rr.querySelectorAll('[data-hp="device"]').length > 0;
out.staticDeviceFoundByEntity = !!rr.querySelector('[data-hp="device"][data-entity="lock.front_door"]');
out.staticNoUndefined = !rr.innerHTML.includes('="undefined"');
out.staticRoomLabelHooked = rr.querySelectorAll('div.roomlabel[data-hp="room-label"]').length > 0;
out.staticRoomLabelFoundByArea = !!rr.querySelector('[data-hp="room-label"][data-area="bedroom"]');
// а вот проёмов и декора она не рисует вовсе (§5) — и это не регрессия
out.staticDrawsNoOpenings = rr.querySelectorAll('[data-hp="opening"]').length === 0;
out.staticDrawsNoDecor = rr.querySelectorAll('[data-hp="decor"]').length === 0;
return out;
});
checkAll(stat);
await finish(browser, { ...res, ...stat });
+10 -12
View File
@@ -1,6 +1,6 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// day/night background vs static, per-space inheritance, weather independence,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
@@ -124,19 +124,16 @@ const res = await page.evaluate(async () => {
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
// 10) legacy weather settings and live weather states no longer participate
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
const stopRain = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.rainDoesNotHideRays = domPolys().length > 0 && Math.abs(stopRain - 0.3) < 1e-6;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
const stopCloudy = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudsDoNotDimRays = domPolys().length > 0 && Math.abs(stopCloudy - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
@@ -183,9 +180,9 @@ const dlg = await page.evaluate(() => {
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasNoWeatherList: sr.querySelectorAll('#hp-weather-list option').length === 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
c._settingsDialog = null;
@@ -238,6 +235,7 @@ await page.evaluate(async () => {
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await page.waitForTimeout(220); // stage coordinates settle with editor chrome
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
+243
View File
@@ -0,0 +1,243 @@
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// legacy/docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, 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 white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a legacy weather setting cannot hide the rim -------------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.rimIgnoresRain = rimsOf().length === 2;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimStaysAfterLegacyCleanup = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
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 d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
+8 -4
View File
@@ -8,6 +8,10 @@ const out = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'automation.evening': { entity_id: 'automation.evening', platform: 'automation', disabled_by: null },
'script.curtains': { entity_id: 'script.curtains', platform: 'script', disabled_by: null },
'scene.movie': { entity_id: 'scene.movie', platform: 'scene', disabled_by: null } },
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
@@ -22,13 +26,13 @@ const out = await page.evaluate(async () => {
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
// пользователя 2026-07-30, поймано только замером высоты)
const listEl = () => sr().querySelector('.dialog .candlist');
const listEl = () => sr().querySelector('hp-dialog .candlist');
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
const cands = [...sr().querySelectorAll('hp-dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// и найденная строка реально нарисована, а не сплющена
const row = sr().querySelector('.dialog .cand');
const row = sr().querySelector('hp-dialog .cand');
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
// сохранение без цели блокируется
@@ -37,7 +41,7 @@ const out = await page.evaluate(async () => {
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
o.confirmCheckboxShown = [...sr().querySelectorAll('hp-dialog .srcrow')].length >= 2;
await c._saveMarker(); await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
+11 -4
View File
@@ -20,13 +20,19 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
// _roomArea consumes the rendered room geometry (0..1000), not the stored
// normalized config polygon (0..1).
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
out.tipHasArea = !!expectedArea && c._tip?.meta === c._t('tip.area', { value: expectedArea });
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
c._tip = null;
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
const hpDialog = sr().querySelector('hp-dialog');
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.srcrow');
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
@@ -39,13 +45,14 @@ const res = await page.evaluate(async () => {
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
// Geometry values were re-baselined for the shared border-box hp-dialog in
// v1.59.2; the usable wide-dialog width remains 500 px.
checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"tempInputs": 2,
"dialogWidth": 502,
"dialogWidth": 500,
});
await finish(browser, res);
+5 -1
View File
@@ -11,7 +11,11 @@ const out = await page.evaluate(async () => {
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
const M = [0.5, 0, 0, 0, 0.5, 0];
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
c.hass = { ...c.hass, states: { ...c.hass.states,
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'vacuum.robo': { entity_id: 'vacuum.robo', platform: 'demo', disabled_by: null },
'camera.robo_map': { entity_id: 'camera.robo_map', platform: 'demo', disabled_by: null } },
states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
} };
+2 -2
View File
@@ -64,7 +64,7 @@ const out = await page.evaluate(async () => {
// ---- manual fit from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
@@ -76,7 +76,7 @@ const out = await page.evaluate(async () => {
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
+126
View File
@@ -0,0 +1,126 @@
// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/LIVE-TEXT.md §2.1).
// Везде, где карточка печатает состояние ОДНОЙ сущности, она зовёт
// hass.formatEntityState (и hass.formatEntityAttributeValue для атрибута) —
// значит, работают display_precision, локальный разделитель дробной части и
// переводы состояний, а единица не дублируется.
// 1) бейдж display:'value' — «68,4 %» вместо сырых «68.42 %»;
// 2) живой текст декора — то же, плюс перевод 'on' → «Включено»;
// 3) своя единица заменяет единицу форматтера, а не приписывается к ней;
// 4) атрибут идёт через СВОЙ форматтер, а не через форматтер состояния;
// 5) старый HA без форматтера — прежнее поведение слово в слово;
// 6) плашки °/% остаются нашими: это производное значение, а не состояние.
// ПАДАЕТ на сборке до этих правок: бейдж и живой текст печатают сырое
// состояние, 'on' остаётся 'on', а своя единица приписывается к чужой.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = window.__mkHass();
// ---- сущности стенда: датчик с «лишними» знаками и выключатель ----
const EXTRA = {
'sensor.tank': { entity_id: 'sensor.tank', state: '68.42',
attributes: { friendly_name: 'Бак', unit_of_measurement: '%' } },
'switch.pump': { entity_id: 'switch.pump', state: 'on',
attributes: { friendly_name: 'Насос' } },
'climate.hall': { entity_id: 'climate.hall', state: 'heat',
attributes: { friendly_name: 'Котёл', current_temperature: 21.53, unit_of_measurement: '°C' } },
};
// Стаб ровно того, что делает HA: display_precision = 1, русская локаль
// (запятая), перевод состояний и единица ВНУТРИ результата.
const haFormat = (st) => {
if (st.state === 'on') return 'Включено';
if (st.state === 'off') return 'Выключено';
if (st.state === 'heat') return 'Нагрев';
if (st.state === 'locked') return 'Заперто';
const n = Number(st.state);
if (!Number.isFinite(n)) return st.state;
const txt = n.toFixed(1).replace('.', ',');
const u = st.attributes?.unit_of_measurement;
return u ? `${txt} ${u}` : txt;
};
const haAttr = (st, a) => String(st.attributes?.[a]).replace('.', ',');
const mk = (withFormatter) => {
const h = {
...base,
states: { ...base.states, ...EXTRA },
entities: { ...base.entities,
'sensor.tank': { entity_id: 'sensor.tank', platform: 'demo' },
'switch.pump': { entity_id: 'switch.pump', platform: 'demo' },
'climate.hall': { entity_id: 'climate.hall', platform: 'demo' } },
};
if (withFormatter) { h.formatEntityState = haFormat; h.formatEntityAttributeValue = haAttr; }
else { delete h.formatEntityState; delete h.formatEntityAttributeValue; }
return h;
};
const apply = async (withFormatter) => {
c.hass = mk(withFormatter);
c._regSignature = ''; c._cfgEpoch++; c._maybeRebuildDevices(); c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
};
// ================= 1. бейдж «значение вместо значка» =================
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_tank', binding: 'entity:sensor.tank', space: c._space, display: 'value' },
] };
await apply(true);
const badge = () => sr().querySelector('[data-hp="device"][data-id="m_tank"] .valtext');
out.badgeRendered = !!badge();
out.badgeIsHaFormatted = badge()?.textContent.trim() === '68,4 %';
out.badgeUnitNotDoubled = !/%\s*%/.test(badge()?.textContent || '');
// старый HA без форматтера — ровно прежняя строка
await apply(false);
out.badgeFallsBackToRawState = badge()?.textContent.trim() === '68.42 %';
await apply(true);
// ================= 2. живой текст декора =================
c._curSpaceCfg.decor = [
{ id: 'lt_val', kind: 'text', x: 0.2, y: 0.92, text: 'Бак {}', color: '#123456', entity: 'sensor.tank' },
{ id: 'lt_unit', kind: 'text', x: 0.5, y: 0.92, text: '{}', color: '#123456', entity: 'sensor.tank', unit: 'проц.' },
{ id: 'lt_state', kind: 'text', x: 0.8, y: 0.92, text: 'Насос: {}', color: '#123456', entity: 'switch.pump' },
{ id: 'lt_attr', kind: 'text', x: 0.2, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature' },
{ id: 'lt_attru', kind: 'text', x: 0.5, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature', unit: '°C' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const txt = (id) => sr().querySelector(`text.dtext[data-id="${id}"]`)?.textContent.trim();
out.liveTextIsHaFormatted = txt('lt_val') === 'Бак 68,4 %';
out.liveTextUnitNotDoubled = txt('lt_val') === 'Бак 68,4 %' && !/%\s*%/.test(txt('lt_val'));
out.liveTextOwnUnitReplacesTheFormatters = txt('lt_unit') === '68,4 проц.';
out.liveTextStateIsTranslated = txt('lt_state') === 'Насос: Включено';
// атрибут идёт через СВОЙ форматтер: иначе тут было бы «Нагрев»
out.attrUsesTheAttributeFormatter = txt('lt_attr') === '21,53';
out.attrDoesNotInheritTheEntityUnit = txt('lt_attr') === '21,53';
out.attrTakesAnExplicitUnit = txt('lt_attru') === '21,53 °C';
// без форматтера — прежнее поведение слово в слово
await apply(false);
out.liveTextFallsBackToRawState = txt('lt_val') === 'Бак 68.42 %';
out.liveTextFallbackTranslatesNothing = txt('lt_state') === 'Насос: on';
out.attrFallsBackToTheRawAttribute = txt('lt_attr') === '21.53';
await apply(true);
// ================= 3. инфо-карточка =================
const dev = c._devices.find((d) => d.primary === 'lock.front_door');
c._infoCard = dev; c.requestUpdate(); await c.updateComplete;
const rowVals = [...sr().querySelectorAll('.entlist .entrow')]
.map((r) => r.querySelector('.entbtn, .ev')?.textContent.trim());
out.infoCardUsesTheFormatter = rowVals.includes('Заперто');
out.infoCardPrintsNoRawState = !rowVals.includes('locked');
c._infoCard = null; c.requestUpdate(); await c.updateComplete;
// ================= 4. плашки °/% остались нашими =================
// термометр в режиме «значение» по-прежнему показывает компактное «22.4°»:
// это ПРОИЗВОДНОЕ показание (среднее по датчикам зоны / атрибут климата),
// а не состояние одной сущности, и весь план читается как один прибор
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'd_temp', binding: 'device:d_temp', display: 'value' },
] };
await apply(true);
const tempBadge = [...sr().querySelectorAll('.dev.valonly .valtext')].map((e) => e.textContent.trim());
out.tempPlateKeepsItsCompactForm = tempBadge.includes('22.4°');
return out;
});
checkAll(res);
await finish(browser, res);
+273
View File
@@ -0,0 +1,273 @@
/**
* Wall thickness (docs/WALL-THICKNESS.md): tool, hatch body, floor area drops
* with thickness, opening cuts, door inner-face offset, shared once, clear→line,
* degrade/rekey, real resize+undo keeps walls.
*/
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const floorAreaOf = (id) => {
const rooms = c._spaceModel().rooms;
const walls = sp().walls || [];
if (!walls.length) {
const r = rooms.find((x) => x.id === id);
const p = r?.poly;
if (!p) return 0;
let s = 0;
for (let i = 0; i < p.length; i++) {
const a = p[i], b = p[(i + 1) % p.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
const el = sr().querySelector(`[data-hp="room"][data-id="${id}"]`);
const pts = el?.getAttribute('points');
if (pts) {
const poly = pts.trim().split(/\s+/).map((t) => t.split(',').map(Number));
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
return 0;
};
sp().settings = { ...(sp().settings || {}), show_borders: true, hide_openings: undefined };
delete sp().walls;
delete sp().openings;
for (const r of sp().rooms || []) delete r.open_to;
const areaBefore = floorAreaOf('r1');
c._setMode('plan');
c._tool = 'wallthick';
await upd();
out.toolArmed = c._tool === 'wallthick';
out.toolBtn = !!sr().querySelector('.editbar .btn.on ha-icon[icon="mdi:wall"]')
|| [...sr().querySelectorAll('.editbar .btn')].some((b) => b.classList.contains('on') && c._tool === 'wallthick');
const outer = [50, 250];
c._cursorPt = outer;
await upd();
out.hover = !!sr().querySelector('.wallthick-hover');
c._wallThickClick(outer);
await upd();
out.dialogOpen = !!c._wallDialog && !!sr().querySelector('.wallthick-dlg');
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
await upd();
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
const areaAfter = floorAreaOf('r1');
out.areaShrinks = areaAfter > 0 && areaAfter < areaBefore - 1;
// A sun shaft starts at the two room-side corners of a thick opening, not
// on the wall centreline and not at an incidence-narrowed pseudo-aperture.
sp().openings = [{
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
}];
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 240, elevation: 60 },
} } };
c._setMode('view');
await upd();
const sunRay = (c._sunRaysCache?.rays || []).find((r) => r.openingId === 'wtSun');
const sunDepth = (20 / c._cellCm) * c._gridPitch;
const ys = sunRay ? [sunRay.a[1], sunRay.b[1]].sort((a, b) => a - b) : [];
out.sunStartsInnerFace = !!sunRay
&& Math.abs(sunRay.a[0] - (40 + sunDepth / 2)) < 1e-6
&& Math.abs(sunRay.b[0] - (40 + sunDepth / 2)) < 1e-6;
out.sunStartsInnerCorners = !!sunRay
&& Math.abs(ys[0] - 205) < 1e-6 && Math.abs(ys[1] - 295) < 1e-6;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
c._wallDialog = { ...c._wallDialog, value: '25' };
c._wallThickApply(false);
await upd();
const shared = [...sr().querySelectorAll('[data-hp="wall"]')];
out.sharedOnce = shared.length === 1;
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: false,
}];
await upd();
out.doorDrawn = !!sr().querySelector('[data-hp="opening"][data-kind="door"]');
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.openingCutsSlab = (bodyD.match(/\bM\b/g) || []).length >= 2;
const g = sr().querySelector('[data-hp="opening"][data-kind="door"] g g');
const inner = [...(g?.querySelectorAll('g') || [])].find((n) => {
const t = n.getAttribute('transform') || '';
return /translate\(([^)]+)\)/.test(t) && !/translate\(\s*0\s+0\s*\)/.test(t);
});
out.doorInnerFace = !!inner || (sp().walls || []).some((w) => w.cm === 25);
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
c._setMode('view');
await upd();
const cutD = sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.hideKeepsCut = sr().querySelectorAll('[data-hp="opening"]').length === 0
&& (cutD.match(/\bM\b/g) || []).length >= 2;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._wallThickClick(outer);
await upd();
c._wallDialog = { ...c._wallDialog, value: '' };
c._wallThickApply(false);
await upd();
out.cleared = !(sp().walls || []).some((w) => w.cm === 20);
sp().walls = [...(sp().walls || []), { key: '9.99,9.99@1.5708', cm: 15 }];
c._dropLegacySegments();
out.degrade = !(sp().walls || []).some((w) => w.key.startsWith('9.99'));
// Opening a stretch takes TWO clicks since v1.59.0-beta.6: anchor, then the
// second point on the same shared wall. A span over y=150..350 must refuse
// thickness at y=250, and the solid remainder above/below must keep its own.
c._tool = 'boundary';
c._boundaryClick([550, 150]);
c._boundaryClick([550, 350]);
await upd();
out.spanOpened = ((sp().open_spans || []).length === 1);
c._tool = 'wallthick';
c._toast = null;
c._wallThickClick([550, 250]);
await upd();
out.openRefused = !!c._toast && !c._wallDialog;
// the closed parts of the same wall still carry the 25 cm they had
out.solidRemainderKeepsCm = c._intervalCm([550, 140, 550, 148]) === 25
&& c._intervalCm([550, 350, 550, 460]) === 25
&& c._intervalCm([550, 200, 550, 300]) === 0;
c._tool = 'boundary';
c._boundaryClick([550, 250]);
await upd();
out.spanClosed = !(sp().open_spans || []).length;
delete sp().openings;
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '30' };
c._wallThickApply(false);
await upd();
}
out.rekeyReady = !!(sp().walls || []).find((w) => w.cm === 30);
// Real resize: drag shared-wall handle one grid step, then undo (AUD-159B4-01).
c._setMode('plan');
c._tool = 'resize';
c._wallDialog = null;
await upd();
const stageEl = () => sr().querySelector('.stage');
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 pev = (type, target, x, y) => {
if (!target) return;
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: 42, clientX, clientY, button: 0, isPrimary: true,
}));
};
const handles = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')];
let sharedHandle = null;
for (const h of handles) {
const cx = +h.getAttribute('cx'), cy = +h.getAttribute('cy');
if (Math.abs(cx - 550) < c._gridPitch * 2 && cy > 150 && cy < 450) {
sharedHandle = h;
break;
}
}
out.resizeEdgeFound = !!sharedHandle;
const wallsBefore = JSON.stringify(sp().walls || []);
if (sharedHandle) {
const cx = +sharedHandle.getAttribute('cx');
const cy = +sharedHandle.getAttribute('cy');
const step = c._gridPitch;
pev('pointerdown', sharedHandle, cx, cy);
pev('pointermove', sharedHandle, cx + step, cy);
pev('pointerup', sharedHandle, cx + step, cy);
await upd();
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
c._undoGeometry();
await upd();
out.resizeUndoRestores = (sp().walls || []).some((w) => w.cm === 30)
&& JSON.stringify(sp().walls || []) === wallsBefore;
} else {
c._dropLegacySegments();
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
out.resizeUndoRestores = false;
}
out.pixelProbe = !!sr().querySelector('.wallbody');
// AUD-159B7-04: the same screen-depth policy owns both cards. At 1 cm the
// hatch must be suppressed in full and static views; at 20 cm both restore
// it. The static card measures its actual stage width via ResizeObserver.
c._setMode('view');
const parityCfg = JSON.parse(JSON.stringify(c._serverCfg));
const paritySp = parityCfg.spaces.find((x) => x.id === c._space);
paritySp.settings = { ...(paritySp.settings || {}), show_borders: true };
paritySp.walls = (paritySp.walls || []).map((w) => ({ ...w, cm: 1 }));
c._serverCfg = parityCfg;
await upd();
await customElements.whenDefined('houseplan-space-card');
const baseCall = c.hass.callWS.bind(c.hass);
const staticHass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: parityCfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(m);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const t0 = Date.now();
while ((!staticCard.renderRoot?.querySelector('.wallbody') || !(staticCard._stageWidth > 0))
&& Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await staticCard.updateComplete;
out.thinWallFullSolid = !!sr().querySelector('.wallbody.solid');
out.thinWallStaticSolid = !!staticCard.renderRoot?.querySelector('.wallbody.solid');
for (const w of paritySp.walls || []) w.cm = 20;
c._cfgEpoch++;
c.requestUpdate();
staticCard.requestUpdate();
await c.updateComplete;
await staticCard.updateComplete;
out.thickWallFullHatched = !!sr().querySelector('.wallbody:not(.solid)');
out.thickWallStaticHatched = !!staticCard.renderRoot?.querySelector('.wallbody:not(.solid)');
staticCard.remove();
return out;
});
checkAll(res);
await finish(browser, res);
+120
View File
@@ -0,0 +1,120 @@
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
+117
View File
@@ -0,0 +1,117 @@
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
// guard-ом `d.mode !== this._mode`.
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
// промежуточный B ещё не успел воскресить диалог A, но в своём
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
// `dlg: null` поверх чужого черновика — C не получал ничего.
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
// payload: просроченная запись держала диалог (у пространства это план
// целиком в base64) до перезагрузки страницы.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const HP = customElements.get('houseplan-card');
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
const mk = (before) => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(250);
};
const near = (a, b) => Math.abs(a - b) < 1e-6;
// ============ A. две одинаковые карточки не делят вид и черновик =========
const A = mk();
await settle(A);
A._applyView(2.15, 300, 420);
A.requestUpdate(); await A.updateComplete; await sleep(60);
const zoomA = A._zoom;
A._openSpaceDialog('edit', A._space); await A.updateComplete;
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
await A.updateComplete; await sleep(30);
const B = mk(); // вторая карточка, тот же конфиг
await settle(B);
B._setMode('devices'); await B.updateComplete; await sleep(120);
B._applyView(3.35, 200, 200);
B.requestUpdate(); await B.updateComplete; await sleep(60);
const zoomB = B._zoom;
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
// B — самостоятельная карточка, а не наследник A
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
const A2 = mk(A);
A.remove();
await sleep(120); await A2.updateComplete; await sleep(60);
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
A2.remove(); B.remove(); await sleep(20);
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
HP._warmBootReset(400);
let c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
await c.updateComplete; await sleep(30);
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
// такте, ни один таймер воскрешения не успевает отработать
const rb = mk(c);
c.remove();
const rc = mk(rb);
rb.remove();
await sleep(150); await rc.updateComplete; await sleep(60);
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
rc.remove(); await sleep(20);
// ============ C. просроченный диалог освобождает payload =================
HP._warmBootReset(400);
c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
await c.updateComplete; await sleep(30);
c.remove(); await sleep(20);
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
await sleep(900); // TTL 400мс + запас
const st = HP._warmBootStats();
out.cPayloadFreedAfterTtl = st.dlgs === 0;
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
// и после TTL ничего не воскресает
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
out.cNoReviveAfterTtl = !c._spaceDialog;
c.remove();
HP._warmBootReset();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
+17 -6
View File
@@ -9,7 +9,15 @@ const out = await page.evaluate(() => {
const c = window.__card;
const cls = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = c._stateClass(d);
c.hass = saved;
return r;
@@ -33,12 +41,15 @@ const out = await page.evaluate(() => {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action фолбэк на state
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
// Некоторые интеграции не отдают hvac_action: тогда их текущий режим из
// hvac_modes — единственный доступный сигнал фактической работы.
o.modeWithoutActionIsYellow = cls(trv, {
'climate.trv': { state: 'heat_cool', attributes: { hvac_modes: ['off', 'heat', 'cool', 'heat_cool'] } },
}) === 'on';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
// Функциональная роль настоящей лампы желтит значок. Auxiliary light у
// soundbar/media-player, наоборот, отсечён системным role-resolver тестом.
const lamp = { id: 'l', primary: 'light.lamp', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
+13 -2
View File
@@ -86,15 +86,26 @@ out.untouchedEditorNoJump = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
// The previous case has only waited two frames after leaving an editor.
// Let its 180 ms chrome collapse and ResizeObservers settle before taking a
// new baseline; otherwise this case measures the previous transition, not
// the untouched editor round-trip it is meant to verify.
await new Promise((resolve) => setTimeout(resolve, 240));
c._resetZoom();
c._zoomAt(30, 30, 1.6); c._saveZoom();
const before = { ...c._view };
c._setMode('plan'); await raf2();
c._setMode('view'); await raf2();
// Header ResizeObserver publishes its final measured height one frame after
// the 180 ms chrome collapse, then the stage observer performs the last fit.
await new Promise((resolve) => setTimeout(resolve, 320));
const v = c._view;
const beforeCenter = [before.x + before.w / 2, before.y + before.h / 2];
const afterCenter = [v.x + v.w / 2, v.y + v.h / 2];
return Math.abs(c._zoom - 1.6) < 0.01
&& Math.abs(v.x - before.x) < 0.005 && Math.abs(v.y - before.y) < 0.005
&& Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005;
&& Math.hypot(afterCenter[0] - beforeCenter[0], afterCenter[1] - beforeCenter[1]) < 0.01
&& Math.abs(v.w - before.w) < 0.005
&& Math.abs(v.h - before.h) < 0.005;
});
// -- the view zoom still survives a space switch, per space ---------------
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+56 -17
View File
@@ -72,9 +72,10 @@ const LAYOUT = {
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
};
let CFG_REV=1, LAYOUT_REV=1;
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],config_entries:['ce_demo'],entry_type:null,via_device_id:null,disabled_by:null}];}
const DEVICES=Object.fromEntries([
dev('d_light1','Ceiling light','Smart Bulb E27','living_room'),
dev('d_lamp','Floor lamp','Smart Bulb E14','living_room'),
@@ -90,19 +91,27 @@ const DEVICES=Object.fromEntries([
dev('d_gate','Gate','GDO-4','garden'),
]);
const ENTITIES={
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo'},
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo'},
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo'},
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo'},
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo'},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo'},
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo'},
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo'},
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo'},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo'},
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo'},
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo'},
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo',config_entry_id:'ce_demo',disabled_by:null},
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo',disabled_by:null},
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo',disabled_by:null},
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo',disabled_by:null},
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo',disabled_by:null},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo',disabled_by:null},
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo',disabled_by:null},
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo',disabled_by:null},
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo',disabled_by:null},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo',disabled_by:null},
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo',disabled_by:null},
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo',disabled_by:null},
};
let REGISTRY_ACCESS='full';
const REGISTRY_LISTENERS={device_registry_updated:new Set(),entity_registry_updated:new Set()};
const CONNECTION={subscribeEvents:async(cb,event)=>{
const listeners=REGISTRY_LISTENERS[event];
if(!listeners)return()=>{};
listeners.add(cb);
return()=>listeners.delete(cb);
},subscribeMessage:async()=>()=>{}};
let STATES={
'light.ceiling':{entity_id:'light.ceiling',state:'on',attributes:{friendly_name:'Ceiling light'}},
'light.floor_lamp':{entity_id:'light.floor_lamp',state:'off',attributes:{friendly_name:'Floor lamp',linkquality:196}},
@@ -120,12 +129,32 @@ let STATES={
function mkHass(){
return {
language:'en', locale:{language:'en'},
// audit P0-4: the card fail-closes editors without an admin user /
// can_write flag — the demo admin mirrors a real HA admin session.
user:{id:'demo', name:'Demo', is_admin:true},
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
callWS:async (m)=>{
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
return {ok:true,rev:2};
if(m.type==='config/device_registry/list'){
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
return Object.values(DEVICES);
}
if(m.type==='config/entity_registry/list'){
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
return Object.values(ENTITIES);
}
if(m.type==='config_entries/get')return[{entry_id:'ce_demo',domain:'demo',title:'Synthetic Home'}];
if(m.type==='manifest/list')return[{domain:'demo',name:'House Plan Demo'}];
if(m.type==='houseplan/config/get')return{config:CFG,rev:CFG_REV,can_write:true};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT,rev:LAYOUT_REV};
if(m.type==='houseplan/config/set')return{ok:true,rev:++CFG_REV};
if(['houseplan/layout/set','houseplan/layout/update','houseplan/layout/delete'].includes(m.type))
return{ok:true,rev:++LAYOUT_REV};
if(m.type==='houseplan/plan/optimize')return{
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:true};
if(m.type==='houseplan/plan/optimize_undo')return{
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:false};
return {ok:true};
},
callService:async (d,s,data)=>{
const eid=data.entity_id, st=STATES[eid];
@@ -133,7 +162,8 @@ function mkHass(){
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
card.hass=mkHass();}
},
connection:{subscribeEvents:async()=>()=>{}},
connection:CONNECTION,
localize:(key)=>key==='component.demo.title'?'Demo integration':null,
formatEntityState:(st)=>st.state,
};
}
@@ -144,5 +174,14 @@ document.getElementById('host').appendChild(card);
card.hass=mkHass();
window.__card=card;
window.__mkHass=mkHass;
window.__setRegistryAccess=(mode)=>{REGISTRY_ACCESS=mode==='full'?'full':'limited';};
window.__setRegistryDisabled=(kind,id,disabledBy='user')=>{
const row=(kind==='device'?DEVICES:ENTITIES)[id];
if(row){
row.disabled_by=disabledBy;
const event=kind==='device'?'device_registry_updated':'entity_registry_updated';
for(const cb of REGISTRY_LISTENERS[event])cb({action:'update',id});
}
};
</script>
</body></html>
+6 -7
View File
@@ -26,20 +26,19 @@ from homeassistant.helpers.typing import ConfigType
DOMAIN = "demo_robot"
TICK_S = 2 # telemetry cadence
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
DOCK = (3900.0, 2600.0)
DOCK = (6100.0, 3600.0) # inside Under-Stairs Closet
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
# room polygons; the names MUST equal the plan room names — auto-calibration
# matches by name.
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
"1": ("Гостиная", 480, 4320, 4160, 7360),
"2": ("Кухня", 4160, 5120, 7520, 7360),
"3": ("Спальня", 4160, 2400, 7520, 5120),
"4": ("Кабинет", 480, 2400, 2720, 4320),
"5": ("Прихожая", 2720, 2400, 4160, 4320),
"1": ("Kitchen & Living", 900, 3067, 6300, 6367),
"2": ("Hallway", 3200, 1033, 4533, 3800),
"3": ("Guest Bedroom", 4533, 1033, 6300, 3067),
"4": ("Under-Stairs Closet", 5300, 3067, 6933, 4467),
}
# cleaning order: start at the dock in the hallway, end next to it
CLEAN_ORDER = ("5", "4", "1", "2", "3")
CLEAN_ORDER = ("4", "2", "3", "1")
INSET = 350.0 # keep lanes away from walls
LANE_MM = 650.0 # serpentine lane spacing

Some files were not shown because too many files have changed in this diff Show More