Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 709d22e777 Recover the map after a reconnect
The token refresh ran on an interval only, which does not fire while the
process is suspended, so a reconnect came back with a token the proxy
refuses. A refresh on the connection's "ready" event covers that.

A fresh token alone was not enough: the vector source is built from a
TileJSON fetched once and never retried, so a refused request left the map
blank. The style is now re-applied when a token arrives after a refusal,
throttled so a proxy refusing for another reason cannot loop. The raster
layer redraws for the same reason - refused tiles are cached as failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 11:54:13 +02:00
Bram KragtenandClaude Opus 5 cb4e925321 Mock the tile token in the onboarding e2e, and trim comments
The onboarding WebSocket mock throws on any command it does not know, so
asking it for a tile token failed the test - which is the mock doing its
job. Answered like `brands/access_token` next to it, and the tile
requests that follow are answered too, so the result does not depend on
what the dev server does with an unknown /api path.

Also thins out the comments on the files this branch touches: 22% and 17%
comment lines against 2-3% in the map components beside them. What went
was mostly measurement narrative that belongs in the commit and the PR,
not above a constant.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 20:47:57 +02:00
Bram KragtenandClaude Opus 5 69c1735ce6 Cache map tiles past the rotating token, and keep the demo working
The proxy token rotates every half hour and rides in the query string,
which the HTTP cache keys on - so every rotation would refetch every tile
a dashboard has ever shown, and `/api/` is NetworkOnly in the service
worker so nothing was cached there at all. The brands route already
solves exactly this, so map tiles now follow it: a CacheFirst route that
strips the token from the cache key. Bounded to 500 entries and a week,
because tiles are 20-145 kB each and this runs on phones. The TileJSON is
deliberately left on the network - it is the switching point for the tile
source, and caching it would be caching the escape hatch.

The demo has no core to proxy through, so it goes to the upstreams
directly: tiles from OSM, which sets CORS on that endpoint, and glyphs
and sprites from VersaTiles, which sets CORS on everything. Same
Shortbread style, so the demo looks like the product. It sends a referrer
there, which OSM asks a website for and which costs nothing here - one
public site, no instance hostname to leak.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:16:07 +02:00
Bram KragtenandClaude Opus 5 be6461ed9a Load the base map through core's tile proxy
Core now proxies both vector and raster tiles, which is what lets them be
requested with an application User-Agent and without a referrer - a
browser can set neither. So the frontend stops talking to OpenStreetMap
and CARTO directly and goes through /api/map_tiles.

The proxy is token gated, so `ha-map` fetches one over the WebSocket
before setting up, following the brands token pattern: cached at module
level, refreshed well inside its lifetime so a dashboard left open for
days keeps working. The blocking wait is kept to about a second - a
backend without the proxy must not hold the map hostage - and the
remaining retries run in the background to ride through the window after
a restart where the WebSocket is up but the handler is not registered
yet.

Two things that are not obvious and cost a measurement each:

MapLibre's `transformRequest` has to return absolute URLs. Tiles are
fetched from a worker, which has no document to resolve a relative URL
against, and the TileJSON that core serves has relative `tiles`. Measured
with a relative TileJSON on one origin: with absolute URLs the style
loads and 8,456 features render; without them exactly one request is made
- the style - and nothing else loads, with no error reported anywhere.

Leaflet bakes its URL template at layer creation and throws while
building a tile URL if a template variable is undefined. So the raster
layer takes the token as an option Leaflet substitutes per request, which
also means a refreshed token is picked up without recreating the layer,
and an absent one is empty rather than missing: the tiles 403 and the
markers still draw.

The asset pipeline shrinks to generating two styles. Glyphs and sprites
come from the proxy, so the 48 MB build-time download, the digest
verification, the glyph range filtering and the bold-range guard are all
gone, along with 5.4 MB from the wheel. `localIdeographFontFamily` goes
too: the complete glyph set is reachable now, so CJK renders in Noto
rather than a device font.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 19:16:07 +02:00
Bram Kragten 7f86074e07 Revert "Load the base map through core's tile proxy"
This reverts commit 4b9e47079c.
2026-08-27 19:15:16 +02:00
Bram KragtenandClaude Opus 5 4b9e47079c Load the base map through core's tile proxy
Core now proxies both vector and raster tiles, which is what lets them be
requested with an application User-Agent and without a referrer - a
browser can set neither. So the frontend stops talking to OpenStreetMap
and CARTO directly and goes through /api/map_tiles.

The proxy is token gated, so `ha-map` fetches one over the WebSocket
before setting up, following the brands token pattern: cached at module
level, refreshed well inside its lifetime so a dashboard left open for
days keeps working. The blocking wait is kept to about a second - a
backend without the proxy must not hold the map hostage - and the
remaining retries run in the background to ride through the window after
a restart where the WebSocket is up but the handler is not registered
yet.

Two things that are not obvious and cost a measurement each:

MapLibre's `transformRequest` has to return absolute URLs. Tiles are
fetched from a worker, which has no document to resolve a relative URL
against, and the TileJSON that core serves has relative `tiles`. Measured
with a relative TileJSON on one origin: with absolute URLs the style
loads and 8,456 features render; without them exactly one request is made
- the style - and nothing else loads, with no error reported anywhere.

Leaflet bakes its URL template at layer creation and throws while
building a tile URL if a template variable is undefined. So the raster
layer takes the token as an option Leaflet substitutes per request, which
also means a refreshed token is picked up without recreating the layer,
and an absent one is empty rather than missing: the tiles 403 and the
markers still draw.

The asset pipeline shrinks to generating two styles. Glyphs and sprites
come from the proxy, so the 48 MB build-time download, the digest
verification, the glyph range filtering and the bold-range guard are all
gone, along with 5.4 MB from the wheel. `localIdeographFontFamily` goes
too: the complete glyph set is reachable now, so CJK renders in Noto
rather than a device font.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 18:14:59 +02:00
Paul BotteinandGitHub e3b9c6e143 Fix the History timeline labels and tooltip placement (#53843)
* Draw History timeline names in a label column again

* Keep the timeline tooltip level with the hovered row
2026-08-27 17:36:06 +02:00
221f184cb1 Remove no-op User-Agent header from Nominatim requests (#53834)
* Remove no-op User-Agent header from Nominatim requests

`User-Agent` is a forbidden header name for browser fetch/XHR, so the
option passed to both Nominatim calls was silently dropped and the
browser's own User-Agent went out instead. Verified in Chromium against
a local echo server: the header never took effect, the outgoing UA was
the browser default, and zero preflights were received — had it been
applied, the non-safelisted header would have forced an OPTIONS
preflight that Nominatim does not answer.

Identification towards Nominatim is done by the `email` query parameter
both calls already send, which is what the Nominatim usage policy
accepts, so nothing changes on the wire. Drop the misleading dead code
and note in a comment why the header cannot be set from a browser.

`hass` is still needed by both functions for `hass.locale.language`, so
the signatures are unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Apply suggestions from code review

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-27 15:04:28 +00:00
Petar PetrovandGitHub 27cbbdb83f Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:04:18 +02:00
337e88560e Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 16:35:43 +02:00
Paul BotteinandGitHub 9c96c56558 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 16:33:47 +02:00
Aidan TimsonandGitHub 27e0c4492c Refine entity details context section (#53797) 2026-08-27 15:21:26 +01:00
Paul BotteinandGitHub 80889c3cea Keep the row config when changing a device automation type (#53832) 2026-08-27 16:16:25 +02:00
Paul BotteinandGitHub cbbaf51e75 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 16:14:47 +02:00
Petar PetrovandGitHub 9778a54305 Stop a percentage Y axis expanding past 100 (#53824)
A percentage has a real ceiling the way zero is a real floor, but the gap that
keeps series off the plot edges did not know that: a battery reading 20-100%
rounded out to an axis labelled up to 120%, and one sitting flat at 100% — a
device left on the charger — reached 160%.

Recognise the unit at the two line-chart call sites and hold the axis at 100,
mirroring the existing zero clamp. Only while the data stays under it, since
power factor is also reported in % and is signed.
2026-08-27 17:14:04 +03:00
Petar PetrovandGitHub b9125537c0 Show Z-Wave neighbor connections in the network visualization (#53677)
* Show Z-Wave neighbor connections in the network visualization

* Fetch neighbors in bulk and hide them behind a toggle

* Warn that loading neighbor data turns off the adapter
2026-08-27 15:59:52 +03:00
Petar PetrovandGitHub fd6017a963 Guarantee a Y-axis gap on history and statistics charts (#53821)
* Guarantee a Y-axis gap on history and statistics charts

ECharts floors the axis minimum and ceils the maximum to a tick multiple,
so quantized states that land exactly on a tick leave the data flush against
the plot edge — which collapses the climate and humidifier action bands to
zero height, since an area fill is based at the axis minimum.

Widen the auto-scaled extent by a fraction of the span so that same rounding
always has something to round away, keeping the axis anchored at zero for
single-signed data and preserving the window ECharts gives a constant series.

* Widen the Y-axis gap threshold to 2% of the span

A nudge only large enough to break an exact tie left the gap unchanged
whenever the data sat just above a tick rather than on it, so a chart could
still render with a hairline of headroom.

Because ECharts rounds out to a whole tick afterwards, this constant does not
set the size of the resulting gap — it decides which axes count as too flat to
leave alone. At 2% the worst case over the tick cycle goes from nothing to
around 3% of the plot, while every measured chart keeps the framing it had.
2026-08-27 15:57:50 +03:00
Shay RedmondandGitHub e0f5d6d6b3 Small copy improvements for Cloud feature list (#53833) 2026-08-27 08:54:57 -04:00
193ab1b424 Deduplicate in-flight requests (#53786)
* Deduplicate in-flight requests

* Use callWS for in-flight request ownership

* Avoid mutating shared request results

* Deep-freeze shared in-flight request results

Centralize immutability in shareInFlightRequest using deep-freeze so
callers do not need readonly types on shared API shapes.

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-27 12:48:59 +00:00
Maarten LakerveldandGitHub 4a3b6db411 Make digital clock card font size themeable (#53808)
The digital clock hardcoded 1.5rem/3rem/4rem, which ignored
--ha-font-size-scale and could not be overridden from a theme. Use
--ha-clock-card-digital-font-size-{small,medium,large} with defaults based
on the font size scale (24px/48px/64px).

Fixes #51602
2026-08-27 12:05:11 +00:00
Aidan TimsonandGitHub 46271d0435 Show app icons for serial port consumers (#53827) 2026-08-27 13:26:19 +02:00
Aidan TimsonandGitHub d4bc02c86b Fix MQTT subscribe controls layout (#53831) 2026-08-27 13:25:40 +02:00
a12ae9a8ed Expand tree nodes on double-click in the automation editor (#53830)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 11:52:25 +01:00
Maarten LakerveldandGitHub 6095787294 Derive energy CSV time columns from exported rows (#53828)
The CSV header used the timestamps of every statistic in the energy
collection. Since the power sources chart (#27501) that includes the
power statistics, which are fetched at a finer period than the energy
statistics (hourly for a month view, where energy is daily), adding
empty hourly columns between the daily energy values. Collect the rows
first and build the time columns only from the data that is actually
exported.

Fixes #52381
2026-08-27 12:51:33 +02:00
Petar PetrovandGitHub 127702b96f Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 09:51:29 +01:00
Petar PetrovandGitHub 56a6c5d9fb Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 11:49:22 +03:00
Petar PetrovandGitHub 3b686dc3b9 Prompt for unsaved changes on programmatic navigation (#53804)
* Prompt for unsaved changes on programmatic navigation

* Tidy unsaved-changes guard tests

* Drop navigations superseded while an unsaved-changes prompt is open
2026-08-27 11:09:09 +03:00
Aidan TimsonandGitHub c5e8dea750 Fix centering for no history found in graph card feature (#53795)
* Fix centering for no history found in graph card feature

* Drop classMap
2026-08-27 11:08:18 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
29cd46eb9e Update dependency minify-literals to v2.2.0 (#53812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-26 20:24:56 +02:00
Paul BotteinandGitHub 92b3081896 Clarify the replaced device state in pickers and target rows (#53807) 2026-08-26 19:48:52 +02:00
Shay RedmondandGitHub 527319ab5e Remove the unnecessary spacing around the ha-alert components (#53799)
Remove the unnecessary spacing around the subscription ha-alert components
2026-08-26 14:01:38 +00:00
Maarten LakerveldandGitHub 8d3877c388 Fix Expose page crash when filtered by assistant (#53803)
The assistants URL filter can skip creating a row for entities that are
only exposed via Cloud (Alexa/Google) manual config, but the sortable key
was still assigned to the missing row, throwing a TypeError and leaving
the page on an infinite spinner. Only assign the key when the row exists.

Fixes #53689
2026-08-26 13:43:50 +00:00
Aidan TimsonandGitHub 66b9fbd4bf Fix view background config reference (#53802) 2026-08-26 15:40:31 +02:00
Bram Kragten a406abfdcd Merge branch 'rc' into dev 2026-08-26 14:48:07 +02:00
Bram Kragten fddc6e5506 Bumped version to 20260826.0 2026-08-26 14:47:19 +02:00
Petar PetrovandGitHub 69518334bb Keep the frame minimum when a gap marker shares the frame (#53580)
The chart data modules push a null y value to break the line where an
entity was unavailable. downSampleLineData read it with Number(), and
Number(null) is 0, which is not NaN, so the isNaN guard did not fire.
The marker then competed as a real value of 0 and won its frame's
minimum slot whenever the readings were positive, discarding the
frame's actual minimum and widening the rendered gap.

Keep markers out of the min/max comparisons entirely and hold at most
one per frame in its own slot. It is emitted, after the frame's values,
only when no kept value follows it: a marker followed by a value in its
own frame is a gap that closed within one frame, which is about one
device pixel wide and too narrow to show. That check runs per frame at
emit time, so the per-point path stays as it was. Keeping every marker
instead would blow up the output on series that are mostly null, such
as the climate heating dataset, which went from 823 to 14525 points
before this was bounded.

Skipping markers before the numeric work also makes gapped series
faster: 16% on a series with a few gaps, 27% on one that is mostly
gaps. Both now have benchmark coverage, which the gap path lacked.

Mean mode no longer averages markers in as zero.
2026-08-26 15:29:12 +03:00
Petar PetrovandGitHub 7a387004d6 Cache media source resolutions for view backgrounds (#53615)
Cache resolved media source URLs for view backgrounds
2026-08-26 15:28:34 +03:00
Bram Kragten 91c28c2f58 Bumped version to 20260729.7 2026-08-14 09:59:26 +02:00
Paul BotteinandBram Kragten 1f299d98e0 Fix clipped focus ring on media player playback buttons (#53632) 2026-08-14 09:58:33 +02:00
Petar PetrovandBram Kragten a74926aef6 Fix sensor card graph footers not receiving hass on lazy upgrade (#53576) 2026-08-14 09:58:32 +02:00
Bram Kragten 437a4cedf9 Check for wheel on PyPI simple index instead of JSON API (#53549) 2026-08-14 09:58:31 +02:00
Bram Kragten 55913d7505 Fix panels missing updates (#53548) 2026-08-14 09:58:30 +02:00
0bb654c70c Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-14 09:58:30 +02:00
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul BotteinandBram Kragten 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

* fix timing
2026-08-07 16:27:33 +02:00
Bram Kragten 662d817a5e Fix link to different port being handled as internal link (#53530) 2026-08-07 16:27:32 +02:00
Aidan TimsonandBram Kragten 7412ab5578 Remove "dev" from tools actions description (#53528)
* Remove "dev" from tools actions description

* Clarify vt is browser developer tools
2026-08-07 16:27:31 +02:00
Krisjanis LejejsandBram Kragten b2be0bd673 Remove cloud demo controls (#53524) 2026-08-07 16:27:30 +02:00
Bram Kragten a141432546 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-07 16:27:29 +02:00
Paul BotteinandBram Kragten cd50531b67 Stack the sidebar when only one column fits (#53515) 2026-08-07 16:27:29 +02:00
Petar PetrovandBram Kragten f7438f4d0e Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-07 16:27:28 +02:00
Petar PetrovandBram Kragten a7fabb8a8c Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-07 16:26:46 +02:00
Aidan TimsonandBram Kragten 9dab55f39a Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
(cherry picked from commit d03ba15c09)

# Conflicts:
#	test/panels/config/script/script-paste.test.ts
2026-08-07 16:23:38 +02:00
Bram Kragten 7ac98b7fdd Bumped version to 20260729.5 2026-08-05 09:56:45 +02:00
karwostsandBram Kragten 5c1c92e8ca Fix missing label in solar forecast dialog (#53506) 2026-08-05 09:56:31 +02:00
Paul BotteinandBram Kragten bf9765d557 Fix back navigation loop on the cloud page (#53497) 2026-08-05 09:56:30 +02:00
Petar PetrovandBram Kragten b013b1f929 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 09:53:55 +02:00
Petar PetrovandBram Kragten 8026a59e70 Fix wrong month in date picker calendar header (#53453) 2026-08-05 09:50:37 +02:00
Bram Kragten ffa7cf17a6 Bumped version to 20260729.4 2026-08-04 13:19:17 +02:00
karwostsandBram Kragten 5a5cec9060 Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 12:50:36 +02:00
Petar PetrovandBram Kragten bb962ba3d8 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-04 12:50:35 +02:00
d4a59ff1c7 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-04 12:50:34 +02:00
karwostsandBram Kragten f496d842a6 Allow to favorite the manual card (#53445) 2026-08-04 12:50:33 +02:00
Petar PetrovandBram Kragten c14d07c845 Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-04 12:50:32 +02:00
Petar PetrovandBram Kragten 8a1f2e86bd Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-04 12:49:55 +02:00
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul BotteinandBram Kragten 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan TimsonandBram Kragten d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G.andBram Kragten 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwostsandBram Kragten 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram KragtenandSimon Lamon ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <[email protected]>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar PetrovandBram Kragten 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar PetrovandBram Kragten ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-07-30 23:25:15 +02:00
karwostsandBram Kragten 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar PetrovandBram Kragten 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul BotteinandBram Kragten d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul BotteinandBram Kragten 22e646a68a Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:41:11 +02:00
d0ab20479f Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:41:10 +02:00
96 changed files with 5093 additions and 757 deletions
+1
View File
@@ -74,3 +74,4 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+11 -6
View File
@@ -4,6 +4,7 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -89,7 +90,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
function copyMapPanel(staticDir) {
async function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -103,6 +104,10 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -139,7 +144,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
copyMapPanel(staticDir);
await copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -155,7 +160,7 @@ gulp.task("copy-static-demo", async () => {
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -168,7 +173,7 @@ gulp.task("copy-static-cast", async () => {
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -184,7 +189,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
copyMapPanel(paths.gallery_output_static);
await copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -215,7 +220,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
+1
View File
@@ -14,6 +14,7 @@ import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
+85
View File
@@ -0,0 +1,85 @@
// Generates the MapLibre styles for the vector base map.
//
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
// is what lets them be requested with an application User-Agent and without a
// referrer. The styles stay here because they come from @versatiles/style and
// core has no node toolchain to regenerate them with.
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import paths from "../paths.cjs";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
const outputDir = path.resolve(paths.build_dir, "map");
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// the proxy move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
// The builder can only write a tile URL, so the source is repointed afterwards.
// Keyed on there being exactly one source: any other shape means the builder's
// own default host would ship unnoticed.
const useTileJson = (name, style) => {
const sources = Object.values(style.sources);
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
await Promise.all(
// Both themes up front: dark is a real cartography, not an inverted raster.
[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) =>
writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(useTileJson(name, builder(styleOptions)))
)
)
);
};
// Shared so it does not have to be wired into every pipeline separately.
let pending;
export const ensureMapAssets = () => {
pending ??= buildMapAssets();
return pending;
};
gulp.task("build-map-assets", ensureMapAssets);
export const mapAssetsDir = outputDir;
+4 -1
View File
@@ -75,6 +75,7 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -115,6 +116,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
@@ -170,6 +172,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
@@ -203,7 +206,7 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260826.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1
View File
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
+32 -21
View File
@@ -1,13 +1,27 @@
import type { Map, TileLayer } from "leaflet";
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -17,7 +31,11 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -38,21 +56,14 @@ export const setupLeafletMap = async (
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.token
);
return { map, leaflet: Leaflet, baseLayer };
};
+294
View File
@@ -0,0 +1,294 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// Leaflet substitutes any option into the URL template; its types do not.
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
}
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
webGL2Supported = Boolean(context);
// Contexts are scarce; the probe must not keep one.
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webGL2Supported = false;
}
}
return webGL2Supported;
};
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
// mangle its {fontstack} and {range} placeholders.
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
// its glyphs and sprites, which is why those come from VersaTiles.
const DEMO_UPSTREAM = {
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
assets: "https://tiles.versatiles.org/assets",
};
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
style.sprite = [
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
];
Object.values(style.sources).forEach((source) => {
if ("url" in source) {
source.url = DEMO_UPSTREAM.tilejson;
}
});
return style;
};
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
return useDemoUpstream(style);
}
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: new URL(sprite.url, location.href).href,
}));
}
return style;
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
// OSM asks a website for a referrer, and the demo has no instance
// hostname to leak.
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
// blocked worker throws here. Keep it guarded or those lose the fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
let refused = false;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
let contextLost = false;
// Declared first, but only ever called once all three exist.
const handleVisibilityChange = () => {
if (contextLost) {
scheduleSwap();
}
};
const swapToRaster = () => {
vector = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
try {
layer.remove();
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map, token);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding drops it too, and there it comes back on return.
if (!vector || document.hidden) {
return;
}
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
};
glMap.on("webglcontextlost", () => {
contextLost = true;
scheduleSwap();
});
glMap.on("webglcontextrestored", () => {
contextLost = false;
clearTimeout(fallbackTimeout);
});
document.addEventListener("visibilitychange", handleVisibilityChange);
map.on("unload", () => {
// Otherwise the timer revives a map that is already gone.
clearTimeout(fallbackTimeout);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
const applyStyle = (newDarkMode: boolean) => {
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
refused = false;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
};
// A refused request leaves the source dead: the TileJSON is fetched once and
// is never retried, so the style has to be applied again once there is a new
// token. Throttled, or a proxy refusing for another reason loops.
let lastRecovery = 0;
glMap.on("error", (event) => {
if ((event.error as { status?: number } | undefined)?.status !== 403) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
applyStyle(requestedDarkMode);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap,
token: string | undefined
): MapBaseLayer => {
const layer = leaflet
.tileLayer(RASTER_TILE_URL, {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
// Leaflet throws on an undefined template variable, so no token means an
// empty one: the tiles 403 and the markers still draw.
token: token ?? "",
} as TokenTileLayerOptions)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
const unsubscribe = subscribeMapTilesToken((newToken) => {
(layer.options as TokenTileLayerOptions).token = newToken;
// Tiles that 403'd are cached as failures; only a redraw asks again.
layer.redraw();
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode,
token
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map, token);
};
+85 -2
View File
@@ -80,6 +80,67 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -91,7 +152,7 @@ const buildHistoryState = (
return { ...state, from };
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -127,9 +188,28 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -142,6 +222,9 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -156,5 +239,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
await navigate(fallbackPath || "/", { replace: true });
await performNavigation(fallbackPath || "/", { replace: true });
};
@@ -0,0 +1,37 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
+43 -14
View File
@@ -3,6 +3,27 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
const offsetFromCursor = (
cursorX: number,
dom: unknown,
viewW: number,
tipW: number
) => {
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
return Math.max(0, Math.min(x, viewW - tipW));
};
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
@@ -20,21 +41,29 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
/**
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
*/
export const itemTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
return [x, y];
};
+79 -18
View File
@@ -10,12 +10,16 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -82,7 +105,10 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -120,21 +146,34 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -147,18 +186,40 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
}
}
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
@@ -1,4 +1,3 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -13,7 +12,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -24,7 +23,6 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -43,10 +41,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -69,13 +63,6 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -83,7 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -193,19 +180,13 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") ||
widthChanged
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -215,22 +196,14 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -254,56 +227,41 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
return label;
},
hideOverlap: true,
},
},
grid: {
top: insideLabels ? 20 : 10,
top: 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
position: sideTooltipPosition,
position: itemTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -443,10 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
+1
View File
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+121 -26
View File
@@ -1,13 +1,32 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
return getPrecision(nice(range / SPLIT_NUMBER, true));
}
interface YAxisExtentValues {
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, onFractionDigits } = options;
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
const bounds = resolveBounds(values);
onFractionDigits(
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
);
return resolvedMin;
return bounds.min;
},
max: (values) => resolveYAxisBound(max, values),
max: (values) => resolveBounds(values).max,
};
}
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -180,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
described
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -195,29 +197,24 @@ export abstract class HaDeviceAutomationPicker<
};
private async _updateDeviceInfo() {
// Asking a removed device for its automations fails rather than returning
// an empty list.
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
await this._fetchDeviceAutomations(
this.hass.callWS,
this.deviceId
).catch(() => [] as T[])
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
// If there is no value, or if we have changed the device ID, reset the value.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+71 -47
View File
@@ -1,4 +1,4 @@
import { mdiAlertOutline } from "@mdi/js";
import { mdiSwapHorizontal } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
@@ -22,6 +22,7 @@ import {
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
@@ -104,6 +105,14 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/**
* The split devices that can actually replace the current value, when the
* caller knows better than this picker. Narrows the replacement candidates,
* so the user is only asked to choose when there is a real choice.
*/
@property({ attribute: false })
public replacementDeviceIds?: string[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -187,7 +196,8 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -203,7 +213,11 @@ export class HaDevicePicker extends LitElement {
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
const candidates = split.split_ids.filter(
(id) =>
selectableIds.has(id) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
return { candidates, primaryId: split.primary_id };
}
);
@@ -250,26 +264,26 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
// The removed device has no name left to show, so say what happened
// to it instead. The alert below names the replacements. Without a
// replacement, fall back to the normal "not found" display.
if (isReplaced) {
return html`
<ha-svg-icon
slot="start"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
.path=${mdiSwapHorizontal}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
@@ -399,31 +413,23 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
canReplace
);
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
@@ -443,14 +449,9 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -464,30 +465,52 @@ export class HaDevicePicker extends LitElement {
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
// The split devices all inherit the composite's name, so the integration is
// what tells them apart.
const replacementDevice =
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
const replacementName = replacementDevice
? computeDeviceName(replacementDevice)
: undefined;
const replacementDomain = replacementDevice?.primary_config_entry
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
replacementName && replacementDomain
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
replacementDomain
),
}
)
: replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
variant="warning"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
}
</ha-button>
</ha-alert>
`;
@@ -498,7 +521,8 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
this._getItems(),
this.replacementDeviceIds
);
if (!replacement?.candidates.length) {
return;
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: 8px;
padding: var(--ha-alert-padding, 8px);
display: flex;
}
.icon {
+10 -1
View File
@@ -111,6 +111,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -148,7 +153,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("value")) {
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
}
@@ -287,6 +295,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
+41 -2
View File
@@ -25,9 +25,11 @@ import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { ensureMapTilesToken } from "../../data/map_tiles";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import {
@@ -160,6 +162,8 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -211,6 +215,7 @@ export class HaMap extends ReactiveElement {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
}
// the control went away with the map, so don't hold on to it
@@ -308,6 +313,7 @@ export class HaMap extends ReactiveElement {
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._baseLayer?.setDarkMode(this._darkMode);
}
private _loading = false;
@@ -322,11 +328,31 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
// The tiles are proxied by core behind a token, so nothing loads without
// one. A host that provides no connection, or a backend without the
// proxy, leaves the map without tiles rather than failing to set up.
const token = this._connection
? await ensureMapTilesToken(this._connection.connection)
: undefined;
const setup = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
token,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
// would leave a live map - and its WebGL context - on a detached host,
// and its container too initialized to set up again on reconnect.
if (!this.isConnected) {
setup.map.remove();
return;
}
this.leafletMap = setup.map;
this.Leaflet = setup.leaflet;
this._baseLayer = setup.baseLayer;
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -891,9 +917,22 @@ export class HaMap extends ReactiveElement {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
.leaflet-tile-pane {
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The only two rules the MapLibre canvas needs from its stylesheet, the
rest of it styles controls and popups we do not render. */
.maplibregl-map {
position: relative;
overflow: hidden;
}
.maplibregl-canvas {
position: absolute;
top: 0;
left: 0;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -7,6 +7,7 @@ import {
mdiHome,
mdiLabel,
mdiMinusBox,
mdiSwapHorizontal,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -177,36 +178,51 @@ export class HaTargetPickerItemRow extends LitElement {
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
: canMigrate
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${
stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)
}
>
</ha-state-icon>
`
: nothing
}
</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
<span slot="headline"
>${
canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced_headline"
)
: name
}</span
>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
? replacement!.candidates.length === 1 && replacement!.name
? this.hass.localize(
"ui.components.target-picker.device_replaced_by_one",
{ device: replacement!.name }
)
: this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
@@ -256,7 +272,7 @@ export class HaTargetPickerItemRow extends LitElement {
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
"ui.components.target-picker.replace_update"
)}
</ha-button>
`
@@ -548,75 +564,73 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
referencedAreas = referencedAreas.filter((area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
);
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
});
}
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
);
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
}
entries.referenced_entities = entries.referenced_entities.filter(
const referencedEntities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -631,9 +645,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
entries.referenced_areas.includes(entity.area_id)) ||
referencedAreas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
entries.referenced_devices.includes(entity.device_id || "")
referencedDevices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -648,7 +662,12 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = entries;
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
+34
View File
@@ -480,6 +480,40 @@ export const migrateAutomationConfig = <
return config;
};
// The fields of the row holding a trigger, condition or action, as opposed to
// the configuration of its type. The UI editors of some types build their value
// from scratch, so these have to be carried over explicitly.
export const TRIGGER_ROW_CONFIG_KEYS = [
"alias",
"note",
"id",
"enabled",
"variables",
] as const;
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
export const ACTION_ROW_CONFIG_KEYS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
export const pickRowConfig = <T extends object>(
row: T,
keys: readonly string[]
): Partial<T> => {
const source = row as Record<string, unknown>;
const config: Record<string, unknown> = {};
for (const key of keys) {
if (key in source) {
config[key] = source[key];
}
}
return config as Partial<T>;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[],
report?: AutomationMigrationReport
+58 -15
View File
@@ -182,26 +182,69 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => Object.is(a[property], b[property]));
// An entity can be referenced by its registry id or by its entity id, and the
// two sides do not have to agree.
const deviceAutomationsSameEntity = (
entityRegistry: EntityRegistryEntry[],
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
return (
a.entity_id === b.entity_id ||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
);
};
// A device exposes the same automation type once per entity, so the same type
// on another entity is a different automation, not an equivalent one.
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
entityRegistry: EntityRegistryEntry[],
automations: T[],
automation: DeviceAutomation
): T | undefined =>
automations.find(
(candidate) =>
deviceAutomationsSameType(candidate, automation) &&
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
);
// Among the split devices that replaced a removed device, the ones that offer
// the given automation. Nothing in the registry says which of them took it over,
// so each candidate has to be asked.
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: DeviceAutomation,
compositeSplits: DeviceCompositeSplits,
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
): Promise<string[]> => {
const candidates =
compositeSplits[automation.device_id]?.split_ids.filter(
(id) => id in hass.devices
) ?? [];
const automationsPerCandidate = await Promise.all(
candidates.map((id) =>
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
)
);
return candidates.filter((_id, index) =>
findEquivalentDeviceAutomation(
entityRegistry,
automationsPerCandidate[index],
automation
)
);
};
const compareEntityIdWithEntityRegId = (
+35 -28
View File
@@ -1909,20 +1909,13 @@ export const downloadEnergyData = (
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats;
const timeSet = new Set<number>();
Object.values(stats).forEach((stat) => {
stat.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const headers =
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n";
const csv: string[] = [];
csv[0] = headers;
interface CsvRow {
id: string;
type: string;
unit: string;
data: StatisticValue[];
}
const rows: CsvRow[] = [];
const processCsvRow = function (
id: string,
@@ -1930,20 +1923,7 @@ export const downloadEnergyData = (
unit: string,
data: StatisticValue[]
) {
let n = 0;
const row: string[] = [];
row.push(id);
row.push(type);
row.push(unit.normalize("NFKD"));
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
rows.push({ id, type, unit, data });
};
const processStat = function (stat: string, type: string, unit: string) {
@@ -2190,6 +2170,33 @@ export const downloadEnergyData = (
);
}
const timeSet = new Set<number>();
rows.forEach((row) => {
row.data.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const csv: string[] = [
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n",
];
rows.forEach(({ id, type, unit, data }) => {
let n = 0;
const row: string[] = [id, type, unit.normalize("NFKD")];
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
});
const blob = new Blob(csv, {
type: "text/csv",
});
+136
View File
@@ -0,0 +1,136 @@
import type { Connection } from "home-assistant-js-websocket";
import { waitForMs } from "../common/util/wait";
export const MAP_TILES_PATH = "/api/map_tiles";
// Well inside the token's lifetime, as util/brands-url.ts does.
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
// Nothing loads without a token, so the first attempts are awaited - briefly,
// or a backend without the proxy would hold the map hostage. The rest retry in
// the background, for the window after a restart where the WebSocket is up but
// the handler is not registered yet.
const BLOCKING_DELAYS_MS = [0, 400, 1000];
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
let token: string | undefined;
let refreshInterval: ReturnType<typeof setInterval> | undefined;
let watchingConnection = false;
let activeConnection: Connection | undefined;
let refreshing: Promise<void> | undefined;
const listeners = new Set<(token: string) => void>();
const fetchToken = async (connection: Connection): Promise<void> => {
const result = await connection.sendMessagePromise<{ token: string }>({
type: "map_tiles/access_token",
});
if (result.token !== token) {
token = result.token;
listeners.forEach((listener) => listener(token!));
}
};
const attempt = async (connection: Connection, delays: number[]) => {
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
for (const delay of delays) {
if (token) {
return;
}
if (delay) {
await waitForMs(delay);
}
try {
await fetchToken(connection);
return;
} catch {
// try next delay
}
}
/* eslint-enable no-await-in-loop */
};
export const ensureMapTilesToken = async (
connection: Connection
): Promise<string | undefined> => {
activeConnection = connection;
if (!token) {
await attempt(connection, BLOCKING_DELAYS_MS);
}
if (!token) {
attempt(connection, BACKGROUND_DELAYS_MS).then(() =>
scheduleRefresh(connection)
);
return undefined;
}
scheduleRefresh(connection);
return token;
};
const scheduleRefresh = (connection: Connection) => {
if (token && !refreshInterval) {
refreshInterval = setInterval(() => {
fetchToken(connection).catch(() => {
// Keep the current token; the next interval retries.
});
}, TOKEN_REFRESH_MS);
}
if (!watchingConnection) {
watchingConnection = true;
// The interval does not fire while the process is suspended, so the token
// can be stale before it comes round; reconnecting is the reliable signal.
connection.addEventListener("ready", () => {
fetchToken(connection).catch(() => {
// Nothing to do; the interval keeps trying.
});
});
}
};
/**
* Forces a new token, for when the current one is refused. Deduplicated: a
* refused map produces one of these per tile.
*/
export const refreshMapTilesToken = (): Promise<void> => {
if (!activeConnection) {
return Promise.resolve();
}
refreshing ??= fetchToken(activeConnection)
.catch(() => {
// Leave the old token in place; a reconnect or the interval retries.
})
.finally(() => {
refreshing = undefined;
});
return refreshing;
};
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
export const subscribeMapTilesToken = (
listener: (token: string) => void
): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
/**
* MapLibre hands tile URLs to a worker, which has no document to resolve a
* relative URL against, so the result has to be absolute.
*/
export const withMapTilesToken = (url: string): string => {
let parsed: URL;
try {
parsed = new URL(url, location.href);
} catch {
return url;
}
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
parsed.searchParams.set("token", token);
}
return parsed.href;
};
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+2 -4
View File
@@ -40,8 +40,7 @@ export const searchPlaces = (
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
}&[email protected]`
).then((res) => {
if (res.ok) {
return res.json();
@@ -59,8 +58,7 @@ export const reverseGeocode = (
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
}&format=jsonv2&[email protected]`
).then((res) => {
if (res.ok) {
return res.json();
-9
View File
@@ -160,15 +160,6 @@ export const getRecorderEntityOptions = (
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
});
export const getStatisticMetadata = (
hass: HomeAssistant,
statistic_ids?: string[]
+21
View File
@@ -0,0 +1,21 @@
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
import type { HomeAssistant } from "../types";
import type { StatisticsMetaData } from "./recorder";
type StatisticIdsType = "mean" | "sum";
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
export const getStatisticIds = (
hass: StatisticIdsApi,
statistic_type?: StatisticIdsType
) =>
shareInFlightRequest(
hass.callWS,
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
() =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
})
);
+10 -4
View File
@@ -14,6 +14,7 @@ import {
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
export const TARGET_SEPARATOR = "________";
@@ -54,13 +55,18 @@ export const extractFromTarget = async (
target: HassServiceTarget,
expandGroup = false,
primaryEntitiesOnly = true
) =>
callWS<ExtractFromTargetResult>({
type: "extract_from_target",
) => {
const request = {
type: "extract_from_target" as const,
target,
expand_group: expandGroup,
primary_entities_only: primaryEntitiesOnly,
});
};
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
callWS<ExtractFromTargetResult>(request)
);
};
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
+10
View File
@@ -526,6 +526,16 @@ export const fetchZwaveNetworkStatus = (
});
};
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
export const fetchZwaveNetworkNeighbors = (
hass: HomeAssistant,
entry_id: string
): Promise<Record<number, number[]>> =>
hass.callWS({
type: "zwave_js/network_neighbors",
entry_id,
});
export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
+8 -3
View File
@@ -230,13 +230,13 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
if (!this.isConnected || !this._open || this._params !== expectedParams) {
return;
}
@@ -250,7 +250,12 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
return;
}
+208 -54
View File
@@ -1,9 +1,11 @@
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
@@ -11,8 +13,16 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import type { LocalizeKeys } from "../../common/translations/localize";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -25,6 +35,8 @@ import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
import { brandsUrl } from "../../util/brands-url";
import { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -33,7 +45,10 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -52,6 +67,17 @@ class HaMoreInfoDetails extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _copiedValue?: string;
private _copyFeedbackTimeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = undefined;
this._copiedValue = undefined;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
@@ -93,12 +119,12 @@ class HaMoreInfoDetails extends LitElement {
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const labels =
this.entry?.labels.map((labelId) => ({
id: labelId,
entry: this._labels?.find((label) => label.label_id === labelId),
})) ?? [];
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
@@ -106,6 +132,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
});
}
if (area && areaName) {
@@ -113,6 +140,9 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
icon: area.icon
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (device && deviceName) {
@@ -120,6 +150,7 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (this.entry?.platform && integrationName) {
@@ -129,31 +160,63 @@ class HaMoreInfoDetails extends LitElement {
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
icon: html`<img
slot="end"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: this.entry.platform,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`,
});
}
const entityEntries: DetailEntry[] = [
contextEntries.push(
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
copyable: true,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
},
];
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
}
);
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
entity_id: this.params.entityId,
labels: labelNames,
},
@@ -171,17 +234,9 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<ha-grouped-list>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
@@ -191,14 +246,6 @@ class HaMoreInfoDetails extends LitElement {
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
@@ -284,17 +331,72 @@ class HaMoreInfoDetails extends LitElement {
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href && !entry.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
if (entry.copyable) {
return html`
<ha-list-item-button
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.copy_value",
{ label, value: entry.value }
)}
data-value=${entry.value}
@click=${this._copyValue}
>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
<ha-svg-icon
class=${this._copiedValue === entry.value ? "copy-success" : ""}
slot="end"
.path=${
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
}
></ha-svg-icon>
</ha-list-item-button>
`;
}
return html`
<ha-list-item-button .href=${entry.href}>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
${entry.icon ?? nothing}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
});
}
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const value = ev.currentTarget.dataset.value;
if (value === undefined) {
return;
}
await copyToClipboard(value);
const duration = 4000;
this._copiedValue = value;
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = window.setTimeout(() => {
this._copiedValue = undefined;
this._copyFeedbackTimeout = undefined;
}, duration);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
duration,
});
}
private _renderAttributes(attributes: { name: string; label: string }[]) {
@@ -358,11 +460,63 @@ class HaMoreInfoDetails extends LitElement {
}
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-4);
margin-top: var(--ha-space-6);
}
a {
color: var(--primary-color);
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
--ha-row-item-gap: var(--ha-space-3);
--mdc-icon-size: 20px;
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-button img {
width: 20px;
height: 20px;
object-fit: contain;
}
.link-row {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-3);
}
.link-row .label {
flex: 1;
color: var(--secondary-text-color);
}
.link-row .value {
max-width: 60%;
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
.labels {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--ha-space-1);
}
.labels ha-label {
min-width: 0;
max-width: 100%;
}
ha-icon-next {
color: var(--secondary-text-color);
}
ha-svg-icon.copy-success {
color: var(--success-color);
}
.empty {
+12 -2
View File
@@ -28,6 +28,7 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
@@ -47,7 +48,10 @@ import {
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import {
createMoreInfoUrl,
decodeMoreInfoUrl,
} from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -231,7 +235,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
// Restore the pre-dialog URL only while the URL still carries this
// dialog's deep-link params: navigate() waits for the close only up to
// DIALOG_WAIT_TIMEOUT and may have committed a new URL already.
if (
this._returnUrl &&
decodeMoreInfoUrl(mainWindow.location.search).entityId === this._entityId
) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
+27
View File
@@ -117,6 +117,33 @@ const initRouting = () => {
})
);
// Strip the rotating token from the cache key, or every rotation refetches
// every tile. The TileJSON is deliberately not matched: it is the switching
// point for the tile source, so it stays on the network.
registerRoute(
({ url }) =>
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
new CacheFirst({
cacheName: "map-tiles",
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// A stale token gives a 403; caching that would pin the failure.
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 7,
purgeOnQuotaError: true,
}),
],
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
+22 -4
View File
@@ -24,6 +24,8 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
// Recreate the page when the remaining path (the item id) changes.
itemId?: boolean;
waitForReady?: boolean;
}
@@ -138,10 +140,23 @@ export class HassRouterPage extends ReactiveElement {
}
if (this._currentPage === newPage) {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
const oldRoute = changedProps.get("route");
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
const newTail = route ? this._computeTail(route).path : undefined;
if (
typeof routeOptions === "object" &&
routeOptions.itemId &&
oldTail !== newTail
) {
// Fall through to the normal create path so `load` / loading screen
// still run. itemId pages are not cached, so this is a new element.
this._currentPage = "";
} else {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
return;
}
if (!routeOptions) {
@@ -365,7 +380,10 @@ export class HassRouterPage extends ReactiveElement {
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (routerOptions.cacheAll || routeOptions.cache) {
if (
(routerOptions.cacheAll || routeOptions.cache) &&
!routeOptions.itemId
) {
this._cache[page] = panelEl;
}
}
+17 -24
View File
@@ -1,5 +1,9 @@
import type { LitElement, PropertyValues } from "lit";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import type { UnsavedChangesGuard } from "../common/navigate";
import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../common/navigate";
import type { Constructor } from "../types";
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
@@ -9,45 +13,34 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
if (!isNavigationClick(e)) {
return;
}
const result = await this.promptDiscardChanges();
if (result) {
this._removeListeners();
if (target) {
const newEvent = new MouseEvent(e.type, e);
target.dispatchEvent(newEvent);
}
}
};
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
private _removeListeners() {
window.removeEventListener("click", this._handleClick, true);
window.removeEventListener("beforeunload", this._handleUnload);
private _unsavedChangesGuard: UnsavedChangesGuard = {
isDirty: () => this.isDirtyState,
prompt: () => this.promptDiscardChanges(),
};
public connectedCallback(): void {
super.connectedCallback();
registerUnsavedChangesGuard(this._unsavedChangesGuard);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState && this.isConnected) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
this._removeListeners();
window.removeEventListener("beforeunload", this._handleUnload);
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._removeListeners();
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
window.removeEventListener("beforeunload", this._handleUnload);
}
protected async promptDiscardChanges(): Promise<boolean> {
@@ -3,6 +3,10 @@ import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ACTION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
@@ -107,9 +111,8 @@ export default class HaAutomationActionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceActions,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceAction extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -95,13 +99,27 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.action,
compositeSplits,
fetchDeviceActions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -115,6 +133,7 @@ export class HaDeviceAction extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
.hass=${this.hass}
@@ -156,6 +175,19 @@ export class HaDeviceAction extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("action");
if (previous && previous.device_id !== this.action.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -185,6 +217,15 @@ export class HaDeviceAction extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.action, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import "@home-assistant/webawesome/dist/components/tree/tree";
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
import { consume, type ContextType } from "@lit/context";
@@ -360,6 +361,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
@@ -1444,6 +1446,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._toggleItem(targetId, false);
}
private _handleDoubleClick(ev: MouseEvent) {
// the expand button and non-selectable items already toggle on single click
if (
ev
.composedPath()
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
) {
return;
}
const item = (ev.target as HTMLElement).closest<
WaTreeItem & { target: string }
>("wa-tree-item");
if (!item || item.isLeaf || item.preventSelection) {
return;
}
// avoid leaving the label text selected by the double click
window.getSelection()?.removeAllRanges();
this._toggleItem(item.target, !item.expanded);
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -7,7 +7,11 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
expandConditionWithShorthand,
pickRowConfig,
} from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
@@ -125,9 +129,8 @@ export default class HaAutomationConditionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,6 +14,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceConditions,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceCondition extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -96,13 +100,27 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.condition,
compositeSplits,
fetchDeviceConditions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -116,6 +134,7 @@ export class HaDeviceCondition extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -157,6 +176,19 @@ export class HaDeviceCondition extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("condition");
if (previous && previous.device_id !== this.condition.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -187,6 +219,15 @@ export class HaDeviceCondition extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.condition, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -896,10 +896,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => resolve(true),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -278,6 +278,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
return;
}
this.readOnly = false;
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
@@ -294,6 +297,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
);
hooks.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
}
if (err.status_code !== 404) {
const alertText =
err.body?.message || err.body || err.error || "Unknown error";
@@ -47,9 +47,11 @@ class HaConfigAutomation extends HassRouterPage {
},
edit: {
tag: "ha-automation-editor",
itemId: true,
},
show: {
tag: "ha-automation-editor",
itemId: true,
},
trace: {
tag: "ha-automation-trace",
@@ -7,6 +7,7 @@ import {
mdiFormatListBulleted,
mdiMenuDown,
mdiShape,
mdiSwapHorizontal,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
@@ -321,14 +322,25 @@ export class HaAutomationRowTargets extends LitElement {
let lastTargetType: string | null = null;
// The collapsed summary hides the individual targets, so carry over the
// warning when any of them no longer exists.
const hasMissingTarget = rows.some(
([targetType, targetId]) => !this._checkTargetExists(targetType, targetId)
);
return html`
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<button
slot="trigger"
class=${classMap({ target: true, warning: hasMissingTarget })}
>
<ha-svg-icon
.path=${hasMissingTarget ? mdiAlert : mdiFormatListBulleted}
></ha-svg-icon>
<div class="label">
${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.targets",
@@ -467,9 +479,15 @@ export class HaAutomationRowTargets extends LitElement {
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
if (
targetType === "device" &&
this._compositeSplits?.[targetId]?.split_ids.some(
(id) => id in this._registries.devices
)
) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
icon = mdiSwapHorizontal;
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
@@ -686,6 +704,9 @@ export class HaAutomationRowTargets extends LitElement {
background-color: var(--ha-color-fill-warning-quiet-resting);
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning ha-svg-icon {
color: var(--ha-color-on-warning-normal);
}
ha-dropdown-item.warning:hover {
background-color: var(--ha-color-fill-warning-quiet-hover);
color: var(--ha-color-on-warning-normal);
@@ -8,7 +8,11 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import {
TRIGGER_ROW_CONFIG_KEYS,
migrateAutomationTrigger,
pickRowConfig,
} from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
@@ -144,9 +148,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...(this.trigger.note ? { note: this.trigger.note } : {}),
...ev.detail.value,
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -16,6 +16,8 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceTriggers,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,6 +44,8 @@ export class HaDeviceTrigger extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -100,13 +104,27 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.trigger,
compositeSplits,
fetchDeviceTriggers
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -120,6 +138,7 @@ export class HaDeviceTrigger extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -161,6 +180,19 @@ export class HaDeviceTrigger extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("trigger");
if (previous && previous.device_id !== this.trigger.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -208,6 +240,15 @@ export class HaDeviceTrigger extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.trigger, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -225,9 +266,6 @@ export class HaDeviceTrigger extends LitElement {
) {
trigger = this._origTrigger;
}
if (this.trigger.id) {
trigger.id = this.trigger.id;
}
fireEvent(this, "value-changed", { value: trigger });
}
@@ -9,6 +9,7 @@ import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -27,15 +28,11 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"alias",
"note",
"id",
"variables",
"enabled",
"options",
] as const;
];
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
private _renderTopCard(): TemplateResult {
return html`
<ha-card outlined>
<ha-card outlined class="summary-card">
<div class="card-content">
<div
class="thank-you-header"
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
<p class="muted">
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
</p>
${this._renderSubscriptionState()}
</div>
${this._renderSubscriptionState()}
</ha-card>
`;
}
@@ -132,6 +132,7 @@ export class CloudAccountOverview extends LitElement {
case "trial":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_title"
@@ -156,6 +157,7 @@ export class CloudAccountOverview extends LitElement {
case "canceled":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_title"
@@ -181,6 +183,7 @@ export class CloudAccountOverview extends LitElement {
case "expired":
return html`
<ha-alert
class="subscription-alert"
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_title"
@@ -636,11 +639,15 @@ export class CloudAccountOverview extends LitElement {
width: auto;
color: var(--primary-text-color);
}
ha-alert {
display: block;
margin-top: var(--ha-space-3);
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
.summary-card {
overflow: hidden;
}
ha-alert ha-button[slot="action"] {
.subscription-alert {
display: block;
--ha-alert-padding: var(--ha-space-3);
}
.subscription-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
@@ -256,10 +256,14 @@ class MqttSubscribeCard extends LitElement {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
ha-button {
align-self: flex-start;
margin-top: var(--ha-space-3);
}
ha-select {
width: 96px;
margin: 0 8px;
}
ha-input {
flex: 1;
@@ -281,10 +285,6 @@ class MqttSubscribeCard extends LitElement {
@media screen and (max-width: 600px) {
ha-select {
display: block;
margin-left: 0px;
margin-top: 8px;
margin-inline-start: 0px;
margin-inline-end: initial;
}
ha-input {
flex: auto;
@@ -1,11 +1,13 @@
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-adaptive-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-value";
import "../../../../../components/list/ha-grouped-list";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@@ -85,64 +87,34 @@ class DialogSerialPortInfo extends LitElement {
}
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.common.copy")}
.path=${mdiContentCopy}
@click=${this._copyToClipboard}
></ha-icon-button>
<ha-grouped-list>
${this._fields().map(
([label, value]) =>
html`<ha-list-item-value .label=${label}
>${value}</ha-list-item-value
>`
)}
</ha-grouped-list>
</ha-adaptive-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
ha-grouped-list {
--ha-list-item-value-max-width: 80%;
}
`;
}
@@ -192,6 +192,16 @@ export class SerialConfigDashboard extends LitElement {
});
}
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
return html`<img
slot="start"
.src=${src}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${alt}
/>`;
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
@@ -202,21 +212,26 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
? this._renderConsumerIcon(
brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
),
consumer.domain!
)
: consumer.kind === "app"
? this._renderConsumerIcon(
`/api/hassio/addons/${consumer.slug}/icon`,
consumer.slug!
)
: html`<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
></ha-svg-icon>`
}
<div slot="headline">${this._consumerName(consumer)}</div>
<ha-icon-next slot="end"></ha-icon-next>
@@ -1,3 +1,4 @@
import { mdiSpiderWeb } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -16,6 +17,7 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
@@ -25,6 +27,7 @@ import type {
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkNeighbors,
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
@@ -33,6 +36,7 @@ import {
import "../../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
@customElement("zwave_js-network-visualization")
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@@ -55,11 +59,19 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
@state() private _neighbors?: Record<number, number[]>;
@state() private _showNeighbors = false;
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
private _neighborLinks = new Set<string>();
private _loadingNeighbors = false;
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
@@ -89,6 +101,32 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
return subscriptions;
}
private async _toggleNeighbors() {
this._showNeighbors = !this._showNeighbors;
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
return;
}
// fetched on demand: reading neighbors turns the radio off briefly
this._loadingNeighbors = true;
try {
this._neighbors = await fetchZwaveNetworkNeighbors(
this.hass,
this.configEntryId
);
} catch (err: unknown) {
this._showNeighbors = false;
showToast(this, {
message:
(err as { message?: string }).message ??
this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbors_error"
),
});
} finally {
this._loadingNeighbors = false;
}
}
public connectedCallback() {
super.connectedCallback();
this._fetchNetworkStatus();
@@ -116,13 +154,23 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
.searchFilter=${this._searchFilter}
.data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics
this._nodeStatistics,
this._showNeighbors ? this._neighbors : undefined
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class=${this._showNeighbors ? "active" : "inactive"}
.path=${mdiSpiderWeb}
.label=${this.hass.localize(
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
)}
@click=${this._toggleNeighbors}
></ha-icon-button>
</ha-network-graph>
</hass-subpage>
`;
@@ -184,6 +232,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
if (this._neighborLinks.has(`${source}>${target}`)) {
return html`${sourceName}${targetName}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbor"
)}</b
>`;
}
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
@@ -263,7 +318,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
private _getNetworkData = memoizeOne(
(
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
neighbors: Record<number, number[]> | undefined
): NetworkData => {
const style = getComputedStyle(this);
const nodes: NetworkNode[] = [];
@@ -420,7 +476,49 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
return { nodes, links, categories };
// Neighbors are the nodes a node can reach directly. They are symmetric
// and carry no signal information, so they fill in the mesh underneath
// the measured routes without overriding them.
const neighborLinks: NetworkLink[] = [];
const neighborKeys = new Set<string>();
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
neighborIds.forEach((neighborId) => {
const target = String(neighborId);
if (!nodeStatuses[neighborId] || target === nodeId) {
return;
}
const [a, b] = [nodeId, target].sort();
const key = `${a}>${b}`;
if (
neighborKeys.has(key) ||
links.some(
(link) =>
(link.source === nodeId && link.target === target) ||
(link.source === target && link.target === nodeId)
)
) {
return;
}
neighborKeys.add(key);
neighborLinks.push({
source: a,
target: b,
// equal values in both directions render the link without an arrow
value: 1,
reverseValue: 1,
lineStyle: {
width: 1,
color: style.getPropertyValue("--disabled-color"),
type: "dashed",
},
// neighbors are plentiful, let the routes shape the layout
ignoreForceLayout: true,
});
});
});
this._neighborLinks = neighborKeys;
return { nodes, links: [...neighborLinks, ...links], categories };
}
);
@@ -460,6 +558,17 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-input-search {
flex: 1;
}
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
ha-icon-button[slot="button"] {
background: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
--ha-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
ha-icon-button[slot="button"].inactive {
color: var(--state-inactive-color);
}
`,
];
}
@@ -40,6 +40,7 @@ class HaConfigScene extends HassRouterPage {
},
edit: {
tag: "ha-scene-editor",
itemId: true,
},
},
};
+33 -18
View File
@@ -911,11 +911,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
this._unsubscribeEvents = unsubscribe;
}
private _showMoreInfo(ev: Event) {
@@ -928,6 +932,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
try {
config = await getSceneConfig(this.hass, this.sceneId!);
} catch (err: any) {
if (!this.isConnected) {
return;
}
await showAlertDialog(this, {
text:
err.status_code === 404
@@ -943,6 +950,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (!this.isConnected) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -1115,20 +1126,24 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (this.isDirtyState) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (!this.isDirtyState) {
return true;
}
return true;
const confirmed = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (confirmed) {
this._markDirtyStateClean();
}
return confirmed;
}
private async _duplicate() {
@@ -40,9 +40,11 @@ class HaConfigScript extends HassRouterPage {
},
edit: {
tag: "ha-script-editor",
itemId: true,
},
show: {
tag: "ha-script-editor",
itemId: true,
},
trace: {
tag: "ha-script-trace",
+6 -1
View File
@@ -804,10 +804,15 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => resolve(true),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -52,11 +52,11 @@ import type {
} from "../../../../data/recorder";
import {
clearStatistics,
getStatisticIds,
StatisticMeanType,
updateStatisticsIssues,
validateStatistics,
} from "../../../../data/recorder";
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
import {
apiContext,
internationalizationContext,
@@ -395,9 +395,11 @@ export class VoiceAssistantsExpose extends LitElement {
aliases: entry?.aliases || [],
};
}
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
if (result[entityId]) {
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
}
});
}
return Object.values(result);
+1 -7
View File
@@ -266,7 +266,6 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -810,12 +809,7 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
padding: 16px;
}
.progress-wrapper {
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
}
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="container no-history-found">
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
@@ -280,6 +280,14 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.no-history-found {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);
@@ -217,22 +217,34 @@ export class HuiClockCardDigital extends LitElement {
"hour minute second"
"hour minute am-pm";
font-size: 1.5rem;
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-weight: var(--ha-font-weight-medium);
line-height: 0.8;
direction: ltr;
}
.time-title + .time-parts {
font-size: 1.5rem;
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
}
.time-parts.size-medium {
font-size: 3rem;
font-size: var(
--ha-clock-card-digital-font-size-medium,
calc(48px * var(--ha-font-size-scale))
);
}
.time-parts.size-large {
font-size: 4rem;
font-size: var(
--ha-clock-card-digital-font-size-large,
calc(64px * var(--ha-font-size-scale))
);
}
.time-parts.size-medium .time-part.second,
@@ -0,0 +1,39 @@
/**
* When battery is charging from grid, per-source import attribution is
* ambiguous if multiple grid sources have data in the same period.
* Rewrites single-source periods in place on `fromGridBySource`, and returns
* a combined used-grid map for multi-source periods. Returns undefined when
* no combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsedGrid(
fromGridBySource: Record<string, Record<number, number>>,
gridToBattery: Record<number, number>,
usedGrid: Record<number, number>
): Record<number, number> | undefined {
const used_grid: Record<number, number> = {};
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(fromGridBySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
fromGridBySource[source][start] = usedGrid[start];
} else {
Object.values(fromGridBySource).forEach((stats) => {
delete stats[start];
});
used_grid[start] = usedGrid[start];
}
}
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
}
@@ -44,6 +44,7 @@ import {
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -585,7 +586,8 @@ export class HuiEnergyUsageGraphCard
// Only add solar/battery consumption series when such a source is
// actually configured, otherwise the legend shows empty solar/battery
// entries for grid-only setups.
// entries for grid-only setups. Combined used_grid is a fallback for
// multi-source battery charging; skip it when it has no points.
if (statIdsByCat.solar) {
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
@@ -596,38 +598,14 @@ export class HuiEnergyUsageGraphCard
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = {};
// If we have to_battery and multiple grid sources in the same period, we
// can't determine which source was used. So delete all the individual
// sources and replace with a 'combined from grid' value.
for (const [start, grid_to_battery] of Object.entries(
consumptionData.grid_to_battery
)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string;
for (const [key, stats] of Object.entries(combinedData.from_grid)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1) {
combinedData.from_grid[source!][start] =
consumptionData.used_grid[start];
} else {
Object.values(combinedData.from_grid).forEach((stats) => {
delete stats[start];
});
used_grid[start] = consumptionData.used_grid[start];
}
const used_grid = buildCombinedUsedGrid(
combinedData.from_grid,
consumptionData.grid_to_battery,
consumptionData.used_grid
);
if (used_grid) {
combinedData.used_grid = { used_grid };
}
combinedData.used_grid = { used_grid };
}
const uniqueKeys = summedData.timestamps;
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
@@ -142,21 +142,35 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
const backgroundImage = this._currentBackgroundImage();
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this.config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
const image = this._getBackgroundImage(background) || "";
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
+23 -17
View File
@@ -802,8 +802,10 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"device_replaced_headline": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"device_replaced_by_one": "Replaced by {device}",
"replace_update": "Update",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
@@ -924,10 +926,12 @@
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"device_replaced": "Replaced device",
"device_replaced_by_one": "This device no longer exists. It was replaced by {device}.",
"device_replaced_by_one_integration": "This device no longer exists. It was replaced by {device} from {integration}.",
"device_replaced_by_multiple": "This device no longer exists. It was replaced by {count} devices, pick the one to use here.",
"replace_update": "Update",
"replace_choose": "Choose",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
@@ -1680,10 +1684,9 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
@@ -6441,17 +6444,17 @@
"trial_note": "Try free for a month. No payment information required up front.",
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
"feature_remote_title": "Access from anywhere",
"feature_remote_body": "Securely reach your Home Assistant from any device, with no port forwarding or VPN.",
"feature_remote_body": "Securely and privately reach your Home Assistant when you're away from home. Zero router configuration, port forwarding, or complex VPNs.",
"feature_backup_title": "Backups kept safe",
"feature_backup_body": "Your latest Home Assistant backup, stored off-site and encrypted, ready to restore your full system on first boot.",
"feature_voice_control_title": "Native Alexa and Google support",
"feature_voice_control_body": "Control every Home Assistant device through Amazon Alexa and Google Assistant, so the devices and routines you already rely on keep working. Set up in minutes, not hours.",
"feature_voice_quality_title": "Faster speech for your own assistant",
"feature_voice_quality_body": "More languages and higher accuracy for Home Assistant's privacy-first voice assistant.",
"feature_companion_title": "Companion app",
"feature_companion_body": "Reach your home from your phone anywhere, with location and sensor updates flowing back securely and no ports to open.",
"feature_backup_body": "Your latest Home Assistant backup, automatically stored somewhere safe and only accessible with your private key, ready to restore your full system with one click.",
"feature_voice_control_title": "Alexa and Google Home made simple",
"feature_voice_control_body": "Ease your transition to Home Assistant, use your Google and Alexa speakers to control every device. Set up in minutes, not hours.",
"feature_voice_quality_title": "Fast, accurate voice control",
"feature_voice_quality_body": "More languages and higher accuracy for your own private voice assistant powered by Home Assistant.",
"feature_companion_title": "Secure app connectivity",
"feature_companion_body": "The Home Assistant app on your mobile device forms a secure connection home, encrypting your location and sensor data.",
"feature_support_title": "Support Home Assistant",
"feature_support_body": "Your subscription funds the Open Home Foundation, keeping Home Assistant independent with no ads or data harvesting.",
"feature_support_body": "Your subscription directly funds the Open Home Foundation, keeping Home Assistant independent and free from ads or data harvesting.",
"sign_in": "Sign in",
"sign_in_lead": "Sign in to your Nabu Casa account.",
"email": "Email",
@@ -8400,7 +8403,10 @@
"status": "Status",
"version": "Version",
"data_rate": "Data rate",
"area": "Area"
"area": "Area",
"neighbor": "In range of each other",
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
"neighbors_error": "Failed to load neighbor connections"
},
"node_status": {
"0": "Unknown",
+8
View File
@@ -13,3 +13,11 @@ declare module "echarts/lib/util/states" {
declare module "echarts/lib/chart/sankey/SankeyView" {
export { default } from "echarts/types/src/chart/sankey/SankeyView";
}
declare module "echarts/lib/util/number" {
export * from "echarts/types/src/util/number";
}
declare module "echarts/lib/scale/helper" {
export * from "echarts/types/src/scale/helper";
}
+30
View File
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
+418
View File
@@ -0,0 +1,418 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
// The fallback to raster tiles is what keeps the map working on devices
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
// building the MapLibre map itself fails - a blocked worker, an exhausted
// WebGL context budget. None of that is reachable from the `ha-map` tests,
// which run in jsdom and therefore only ever take the raster branch.
const maplibreLayer = vi.hoisted(() => ({
addTo: vi.fn(),
remove: vi.fn(),
options: {} as { attribution?: string },
getMaplibreMap: vi.fn(),
}));
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
// The token module is driven directly here, so a stale token and the one that
// replaces it can be played out without a WebSocket.
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
vi.mock("../../../src/data/map_tiles", () => ({
MAP_TILES_PATH: "/api/map_tiles",
refreshMapTilesToken,
subscribeMapTilesToken: (listener: (token: string) => void) => {
tokenListeners.add(listener);
return () => tokenListeners.delete(listener);
},
withMapTilesToken: (url: string) => new URL(url, location.href).href,
}));
const emitToken = (token: string) =>
tokenListeners.forEach((listener) => listener(token));
const STYLE = {
version: 8,
sources: {},
layers: [],
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = {
// Leaflet returns the layer from addTo, and the source chains off it.
addTo: vi.fn(() => rasterLayer),
redraw: vi.fn(),
options: {} as Record<string, unknown>,
};
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
} as unknown as LeafletModuleType;
const TOKEN = "test-token";
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, (event?: unknown) => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: (event?: unknown) => void) => {
glHandlers[event] = handler;
}),
};
const map = { on: vi.fn() } as any;
// The WebGL2 probe is cached for the lifetime of the module, so every test
// needs its own copy of it.
const setWebGL2 = async (supported: boolean) => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
supported ? ({ getExtension: () => null } as any) : null
);
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
};
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
tokenListeners.clear();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
for (const event of Object.keys(glHandlers)) {
delete glHandlers[event];
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// Through core's proxy, which is what identifies Home Assistant upstream -
// a browser can set neither a User-Agent nor a Referer.
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
// Leaflet substitutes options into the template on every tile request, so
// the token can be refreshed without recreating the layer.
expect(url).toContain("token={token}");
expect(options).toMatchObject({ token: TOKEN });
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself.
expect(options.attribution).toContain("openstreetmap.org/copyright");
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("falls back to raster tiles when the style cannot be fetched", async () => {
const createBaseLayer = await setWebGL2(true);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("offline");
})
);
await createBaseLayer(leaflet, map, false, TOKEN);
expect(isRaster()).toBe(true);
});
// The plugin only builds the MapLibre map once the layer is added, so this
// is where a blocked worker or a refused WebGL context surfaces.
it("falls back to raster tiles when adding the vector layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
});
it("still falls back when tearing down the half-added layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
maplibreLayer.remove.mockImplementation(() => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false, TOKEN);
expect(isRaster()).toBe(true);
});
});
describe("setDarkMode", () => {
beforeEach(() => {
glMap.setStyle.mockClear();
});
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
baseLayer.setDarkMode(true);
expect(glMap.setStyle).toHaveBeenCalledOnce();
});
// A failed request must roll back to the style that is actually on screen.
// Rolling back to the opposite of the failed request instead would desync
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const failing = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", failing);
// Dark is superseded by light, which then fails: the map is still light.
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
// Both rejections have to land before the retry, or this passes whatever
// the rollback does.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
// Styles are fetched, so a burst of theme changes can resolve out of order.
// Applying a stale one would leave the map on the wrong theme for good.
it("ignores a style that resolves after a newer request", async () => {
const createBaseLayer = await setWebGL2(true);
const resolvers: ((value: unknown) => void)[] = [];
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
})
)
);
const styleResponse = (name: string) => ({
json: async () => ({ ...structuredClone(STYLE), name }),
});
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false, TOKEN);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
// The dark request, which is no longer the newest, comes back last.
resolvers[1](styleResponse("light"));
resolvers[0](styleResponse("dark"));
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
expect(glMap.setStyle.mock.calls[0][0]).toMatchObject({ name: "light" });
});
});
// Browsers cap the number of live WebGL contexts and drop the oldest. With
// more map cards than that cap - measured at 16 in Chrome - the first cards
// lose their context and never get it back, because nothing frees a slot.
describe("WebGL context loss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(maplibreLayer.remove).toHaveBeenCalled();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
vi.runAllTimers();
expect(maplibreLayer.remove).not.toHaveBeenCalled();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
// Backgrounding a tab loses the context too, and there it comes back when
// the page is shown again. Running the clock anyway would mean switching
// apps for a few seconds was enough to come back to a raster map.
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(remove).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function)
);
// And a later visibility change must not add a second raster layer.
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
// A refused request leaves the source dead: the TileJSON is fetched once and
// MapLibre never retries it, so the map stays blank until the style is
// applied again.
describe("recovering from a refused token", () => {
it("asks for a new token and re-applies the style once it arrives", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
expect(refreshMapTilesToken).toHaveBeenCalled();
emitToken("fresh-token");
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
it("does not keep asking while the proxy refuses for another reason", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
for (let i = 0; i < 5; i++) {
glHandlers.error({ error: { status: 403 } });
}
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
});
it("ignores errors that are not a refusal", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glHandlers.error({ error: { status: 500 } });
expect(refreshMapTilesToken).not.toHaveBeenCalled();
});
it("leaves a working map alone when the token is merely refreshed", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
glMap.setStyle.mockClear();
emitToken("fresh-token");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(glMap.setStyle).not.toHaveBeenCalled();
});
it("redraws the raster layer so refused tiles are asked for again", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
emitToken("fresh-token");
expect(rasterLayer.options.token).toBe("fresh-token");
expect(rasterLayer.redraw).toHaveBeenCalled();
});
});
+151 -3
View File
@@ -1,7 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NavigateOptions } from "../../src/common/navigate";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
import type {
NavigateOptions,
UnsavedChangesGuard,
} from "../../src/common/navigate";
import {
canGoBack,
goBack,
navigate,
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
@@ -54,6 +63,145 @@ describe("navigate", () => {
});
});
describe("unsaved changes guard", () => {
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps afterEach able to clean up the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
beforeEach(() => {
setEntry("/config");
});
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
it("navigates without prompting when no guard is dirty", async () => {
const guard = registerGuard(false);
expect(await navigate("/config/areas")).toBe(true);
expect(window.location.pathname).toEqual("/config/areas");
expect(guard.prompt).not.toHaveBeenCalled();
});
it("prompts a dirty guard and navigates when confirmed", async () => {
const guard = registerGuard(true, true);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config/areas");
});
it("leaves history untouched when the prompt is declined", async () => {
const guard = registerGuard(true, false);
const listener = vi.fn();
window.addEventListener("location-changed", listener);
expect(await navigate("/config/areas")).toBe(false);
window.removeEventListener("location-changed", listener);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config");
expect(listener).not.toHaveBeenCalled();
});
it("does not prompt when navigating to the current path", async () => {
const guard = registerGuard(true, false);
expect(await navigate("/config")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("skips a pending prompt once no guard is dirty anymore", async () => {
let dirty = true;
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
const first = navigate("/config/areas");
dirty = false;
expect(await navigate("/config/devices/dashboard")).toBe(true);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(guard.prompt).toHaveBeenCalledOnce();
answerPrompt(true);
await first;
});
it("drops a navigation superseded while its prompt was open", async () => {
let dirty = true;
const { answerPrompt } = trackDeferredGuard(() => dirty);
const superseded = navigate("/config/areas");
dirty = false;
await navigate("/config/devices/dashboard");
answerPrompt(true);
expect(await superseded).toBe(false);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
});
it("shares one pending prompt between concurrent navigations", async () => {
const { guard, answerPrompt } = trackDeferredGuard(() => true);
const first = navigate("/config/areas");
const second = navigate("/config/devices/dashboard");
answerPrompt(true);
await Promise.all([first, second]);
expect(guard.prompt).toHaveBeenCalledOnce();
});
it("stops prompting once the guard is unregistered", async () => {
const guard = registerGuard(true, false);
unregisterUnsavedChangesGuard(guard);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("does not prompt for goBack's fallback navigation", async () => {
const guard = registerGuard(true, false);
await goBack("/config/cloud/account");
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(guard.prompt).not.toHaveBeenCalled();
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("shareInFlightRequest", () => {
it("shares an in-flight request for the same owner and key", async () => {
const owner = {};
const request = deferred<number>();
const fetcher = vi.fn(() => request.promise);
const first = shareInFlightRequest(owner, "resource:a", fetcher);
const second = shareInFlightRequest(owner, "resource:a", fetcher);
expect(first).toBe(second);
expect(fetcher).toHaveBeenCalledTimes(1);
request.resolve(42);
await expect(first).resolves.toBe(42);
await expect(second).resolves.toBe(42);
});
it("does not share requests with different keys", async () => {
const owner = {};
const fetcherA = vi.fn(async () => "a");
const fetcherB = vi.fn(async () => "b");
await Promise.all([
shareInFlightRequest(owner, "resource:a", fetcherA),
shareInFlightRequest(owner, "resource:b", fetcherB),
]);
expect(fetcherA).toHaveBeenCalledTimes(1);
expect(fetcherB).toHaveBeenCalledTimes(1);
});
it("does not share requests between different owners", async () => {
const firstOwner = {};
const secondOwner = {};
const firstFetcher = vi.fn(async () => "first");
const secondFetcher = vi.fn(async () => "second");
await Promise.all([
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
]);
expect(firstFetcher).toHaveBeenCalledTimes(1);
expect(secondFetcher).toHaveBeenCalledTimes(1);
});
it("forgets a request after it resolves", async () => {
const owner = {};
const fetcher = vi.fn(async () => 42);
await shareInFlightRequest(owner, "resource:a", fetcher);
await shareInFlightRequest(owner, "resource:a", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("forgets a request after it rejects", async () => {
const owner = {};
const fetcher = vi
.fn<() => Promise<number>>()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce(42);
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).rejects.toThrow("failed");
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).resolves.toBe(42);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("deep-freezes resolved results", async () => {
const owner = {};
const result = { items: ["a"] };
const fetcher = vi.fn(async () => result);
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
expect(shared).toBe(result);
expect(Object.isFrozen(shared)).toBe(true);
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
expect(() => {
(shared as typeof result).items.push("b");
}).toThrow();
});
});
+204 -6
View File
@@ -19,9 +19,52 @@ const generatePoints = (
return points;
};
const toObjectPoints = (points: [number, number][]) =>
// Gap markers: the chart data modules push a null value to break the line
// where an entity was unavailable.
type GappedPoint = [number, number | null | undefined];
const toObjectPoints = (points: GappedPoint[]) =>
points.map((value) => ({ value }));
const expectXOrdered = (result: { [0]: number }[]) => {
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
};
// A series whose readings are all positive, with an unavailable stretch that
// starts inside the first frame. Mirrors the point sequence
// state-history-chart-line-data.ts emits for a gap.
const gappedPoints: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
[FIXED_EPOCH_MS + 4_001, null], // gap marker
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
// A generated series with three unavailable stretches of different lengths.
const generateGappedPoints = (seed: number, count: number) => {
const points: GappedPoint[] = generatePoints(seed, count);
for (const [start, length] of [
[Math.floor(count * 0.13), 3],
[Math.floor(count * 0.4), 25],
[Math.floor(count * 0.83), 1],
]) {
const gapStart = points[start][0];
points.splice(
start + 1,
length,
[gapStart + 1, points[start][1]],
[gapStart + 1, null]
);
}
return points;
};
describe("downSampleLineData", () => {
it("returns empty array for undefined data", () => {
expect(downSampleLineData(undefined, 100)).toEqual([]);
@@ -66,11 +109,7 @@ describe("downSampleLineData", () => {
});
it("min/max mode preserves x-order for sorted input", () => {
const points = generatePoints(4, 1000);
const result = downSampleLineData(points, 50);
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
});
it("min/max mode matches characterization snapshot", () => {
@@ -193,6 +232,165 @@ describe("downSampleLineData", () => {
).toMatchSnapshot();
});
it("keeps the frame minimum when a gap marker shares the frame", () => {
// Without special handling the marker becomes y=0, wins the minimum slot
// and the real minimum (10) is dropped.
expect(downSampleLineData(gappedPoints, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 3_000, 10],
[FIXED_EPOCH_MS + 4_001, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("drops a marker whose gap closes within the same frame", () => {
// A frame spans about one device pixel, so a gap that opens and closes
// inside one is too narrow to show. The values around it stay.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
[FIXED_EPOCH_MS + 3_000, 60],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 10],
[FIXED_EPOCH_MS + 2_000, 90],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a marker sharing its x with a value after that value", () => {
// statistics-chart-data.ts ends the line and breaks it at the same x
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a gap marker whose frame holds no values", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a single gap marker per frame", () => {
// A run of nulls inside one frame renders the same as a single null: the
// break only depends on which points the marker sits between.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 3_000, null],
[FIXED_EPOCH_MS + 4_000, null],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("bounds the output on a series where most points are null", () => {
// The climate heating/cooling datasets push a null for every state where
// the mode is inactive, so markers must not escape the frame budget.
const values = generatePoints(20, SCALES.medium);
const random = createSeededRandom(21);
const gapped: GappedPoint[] = values.map(([x, y]) =>
random() < 0.65 ? [x, null] : [x, y]
);
const gapless = downSampleLineData(values, 500);
const result = downSampleLineData(gapped, 500);
// Both series share an x grid, so they share frames, and the gapless one
// emits at least one point per frame. A gapped frame emits at most three:
// min, max and a single marker.
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
expect(
result.filter((point) => point[1] === null).length
).toBeLessThanOrEqual(gapless.length);
});
it("handles gap markers on object-shaped points", () => {
const points = toObjectPoints(gappedPoints);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("handles gap markers on Date x values", () => {
// statistics charts use Date objects for x
const points = gappedPoints.map(
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("mean mode leaves gap markers out of the average", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 10],
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 30],
[FIXED_EPOCH_MS + 30_000, 100],
[FIXED_EPOCH_MS + 31_000, 100],
];
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 30_500, 100],
]);
});
it("min/max mode preserves x-order for gapped input", () => {
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
// Of the three gaps only the 25 point one outlasts its frame; the one and
// three point gaps close within theirs and are dropped.
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
expectXOrdered(result);
});
it("large scale mean-mode digest is stable", () => {
expect(
digestResult(
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { SVGRenderer } from "echarts/renderers";
import echarts from "../../../src/resources/echarts/echarts";
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
// jsdom has no canvas, and zrender reaches for one to measure label text.
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 10 }),
})) as any;
// The app registers the canvas renderer; jsdom needs the SVG one.
echarts.use([SVGRenderer]);
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
// assert the extent ECharts actually renders rather than what we hand it.
const renderExtent = (
data: number[],
yAxis: Record<string, unknown>,
nextYAxis?: Record<string, unknown>
): [number, number] => {
const chart = echarts.init(null, null, {
ssr: true,
renderer: "svg",
width: 400,
height: 300,
});
const option = (axis: Record<string, unknown>) => ({
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
yAxis: { type: "value", ...axis },
series: [{ type: "line", data }],
});
chart.setOption(option(yAxis));
if (nextYAxis) {
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
}
// getModel() is internal, but it is the only way to read the rendered extent.
const extent = (chart as any)
.getModel()
.getComponent("yAxis")
.axis.scale.getExtent();
chart.dispose();
return extent;
};
const withGap = (includeZero = false, unit?: string) => ({
scale: !includeZero,
...createYAxisPrecisionBounds({
includeZero,
unit,
onFractionDigits: () => undefined,
}),
});
describe("Y-axis tick nudge", () => {
it("opens a gap under data that lands exactly on a tick", () => {
// The reported bug: a thermostat's states are quantized, so the minimum is
// an exact tick and the HVAC action band has nothing to fill.
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
});
it("leaves an axis with real headroom untouched", () => {
// Its minimum sits well clear of the tick below it, so the rounding it
// already gets is enough and the widened extent floors to the same place.
const data = [18.3, 21.2, 19.5];
expect(renderExtent(data, withGap())).toEqual(
renderExtent(data, { scale: true })
);
});
it("keeps a non-negative series anchored at zero", () => {
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
// Without the clamp the nudged minimum floors a whole interval below zero.
expect(
renderExtent([0, 3500, 1200], {
scale: true,
boundaryGap: [0.02, 0.02],
})[0]
).toBe(-1000);
});
it("keeps the window ECharts gives a constant series", () => {
expect(renderExtent([21, 21, 21], withGap())).toEqual(
renderExtent([21, 21, 21], { scale: true })
);
// A series flat at zero has no magnitude to expand by.
expect(renderExtent([0, 0, 0], withGap())).toEqual(
renderExtent([0, 0, 0], { scale: true })
);
});
it("does not round a percentage axis past 100", () => {
const battery = [20, 100, 60, 20, 80];
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
});
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
// setOption merges the Y axis, so a gap that is only emitted conditionally
// survives the switch and leaves the bars floating.
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
});
});
@@ -72,13 +72,25 @@ describe("computeYAxisFractionDigits", () => {
});
describe("createYAxisPrecisionBounds", () => {
it("computes digits from the visible extent when no bounds are set", () => {
const makeBounds = (
options: Omit<
Parameters<typeof createYAxisPrecisionBounds>[0],
"onFractionDigits"
> = {}
) => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
return {
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
onFractionDigits,
};
};
it("computes digits from the visible extent when no bounds are set", () => {
const { min, max, onFractionDigits } = makeBounds();
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
expect(min({ min: 0, max: 100 })).toBeUndefined();
expect(max({ min: 0, max: 100 })).toBeUndefined();
expect(min({ min: 10, max: 100 })).toBeUndefined();
expect(max({ min: 10, max: 100 })).toBeUndefined();
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
@@ -87,12 +99,7 @@ describe("createYAxisPrecisionBounds", () => {
});
it("computes digits from numeric bounds and returns them unchanged", () => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: 1.85,
max: 2,
onFractionDigits,
});
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
// Fixed bounds pin the range, so the visible extent is ignored
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
@@ -101,11 +108,9 @@ describe("createYAxisPrecisionBounds", () => {
});
it("resolves function bounds and passes their result through", () => {
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
const { min, max, onFractionDigits } = makeBounds({
min: ({ min: dataMin }) => dataMin - 1,
max: ({ max: dataMax }) => dataMax + 1,
onFractionDigits,
});
expect(min({ min: 10, max: 11 })).toBe(9);
@@ -115,11 +120,7 @@ describe("createYAxisPrecisionBounds", () => {
});
it("unions the extent with zero for anchored axes", () => {
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({
includeZero: true,
onFractionDigits,
});
const { min, onFractionDigits } = makeBounds({ includeZero: true });
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
min({ min: 20, max: 25 });
@@ -131,10 +132,102 @@ describe("createYAxisPrecisionBounds", () => {
});
it("does not over-pad when the visible extent collapses to noise", () => {
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
const { min, onFractionDigits } = makeBounds();
min({ min: 0.3, max: 0.3 + 1e-15 });
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
});
it("widens the extent so ECharts always rounds out a gap", () => {
const { min, max, boundaryGap, splitNumber } = makeBounds();
// The gap is applied by ECharts, so the bounds stay auto-scaled.
expect(boundaryGap).toEqual([0.02, 0.02]);
// Pinned rather than assumed, since the precision here is derived from it.
expect(splitNumber).toBe(5);
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
});
it("anchors at zero instead of widening a non-negative series below it", () => {
const { min, max } = makeBounds();
// Without this, ECharts floors the widened -0.0035 to a full interval below
// zero, inventing a negative region on a power chart.
expect(min({ min: 0, max: 3500 })).toBe(0);
expect(max({ min: 0, max: 3500 })).toBeUndefined();
// A minimum clear of the gap is left to ECharts.
expect(min({ min: 100, max: 3500 })).toBeUndefined();
});
it("anchors an all-negative series at zero from above", () => {
const { min, max } = makeBounds();
expect(max({ min: -3500, max: 0 })).toBe(0);
expect(min({ min: -3500, max: 0 })).toBeUndefined();
// Mixed-sign data is widened at both ends.
expect(min({ min: -20, max: 20 })).toBeUndefined();
expect(max({ min: -20, max: 20 })).toBeUndefined();
});
it("keeps the expanded window ECharts gives a constant series", () => {
const { min, max, onFractionDigits } = makeBounds();
// ECharts only expands a flat extent while its bounds are equal, so the gap
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
expect(min({ min: 21, max: 21 })).toBe(10);
expect(max({ min: 21, max: 21 })).toBe(35);
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// A series flat at zero has no magnitude to expand by.
expect(min({ min: 0, max: 0 })).toBe(0);
expect(max({ min: 0, max: 0 })).toBe(1);
});
it("stops a percentage axis at 100 the way it stops at zero", () => {
const { max } = makeBounds({ unit: "%" });
// A battery that reaches full would otherwise round out to an axis
// labelled up to 120%.
expect(max({ min: 20, max: 100 })).toBe(100);
expect(max({ min: 20, max: 99 })).toBe(100);
// Well clear of the ceiling, so it still gets its gap.
expect(max({ min: 40, max: 60 })).toBeUndefined();
// Not every % sensor is bounded: power factor is signed and can read over
// 100, and a series already past the ceiling must be left alone.
expect(max({ min: -80, max: 140 })).toBeUndefined();
});
it("holds a constant percentage under the ceiling too", () => {
const { max } = makeBounds({ unit: "%" });
// A device left on the charger reads a flat 100%, and the expansion a flat
// series gets is a fraction of its magnitude, so it overshoots on its own.
expect(max({ min: 100, max: 100 })).toBe(100);
expect(max({ min: 80, max: 80 })).toBe(100);
// Far enough below that the expansion never reaches the ceiling.
expect(max({ min: 54, max: 54 })).toBe(90);
});
it("only treats a percentage as bounded", () => {
// The same extent without the unit is widened as usual.
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
expect(
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
).toBeUndefined();
});
it("never widens a zero-anchored axis", () => {
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
// Widening below zero would defeat ECharts' anchoring and float the bars.
expect(boundaryGap).toEqual([0, 0]);
expect(min({ min: 0, max: 3500 })).toBeUndefined();
expect(max({ min: 20, max: 20 })).toBeUndefined();
});
});
@@ -173,4 +173,31 @@ describe("ha-target-picker-item-row target extraction", () => {
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
it("does not mutate the extracted target result", async () => {
const result = extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_missing"],
referenced_entities: ["light.missing"],
});
const entries = await extractedBy(
result,
{
areas: {},
devices: {},
entities: {},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries).not.toBe(result);
expect(entries!.referenced_areas).toEqual([]);
expect(entries!.referenced_devices).toEqual([]);
expect(entries!.referenced_entities).toEqual([]);
expect(result.referenced_areas).toEqual(["area_missing"]);
expect(result.referenced_devices).toEqual(["dev_missing"]);
expect(result.referenced_entities).toEqual(["light.missing"]);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import type { DeviceTrigger } from "../../src/data/device/device_automation";
import {
fetchReplacementDevices,
findEquivalentDeviceAutomation,
} from "../../src/data/device/device_automation";
import type { DeviceCompositeSplits } from "../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
import type { HomeAssistant } from "../../src/types";
const entityRegistry = [
{ id: "regid1", entity_id: "binary_sensor.one" },
{ id: "regid2", entity_id: "binary_sensor.two" },
] as EntityRegistryEntry[];
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
({
trigger: "device",
domain: "binary_sensor",
device_id: "device1",
...partial,
}) as DeviceTrigger;
describe("findEquivalentDeviceAutomation", () => {
it("picks the automation on the same entity among several of the same type", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBe(automations[1]);
});
it("matches an entity referenced by entity id against one referenced by registry id", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
)
).toBe(automations[1]);
});
it("returns undefined when the same type is only offered for another entity", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBeUndefined();
});
it("matches entity-less automations on their subtype", () => {
const automations = [
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_1",
}),
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
})
)
).toBe(automations[1]);
});
it("returns undefined when the device offers no automation of that type", () => {
const automations = [
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid1" })
)
).toBeUndefined();
});
});
describe("fetchReplacementDevices", () => {
const hass = {
callWS: () => Promise.resolve([]),
devices: { device2: {}, device3: {} },
} as unknown as HomeAssistant;
const compositeSplits = {
removed: { split_ids: ["device2", "device3"], primary_id: "device2" },
} as unknown as DeviceCompositeSplits;
const value = trigger({
device_id: "removed",
type: "turned_on",
entity_id: "regid1",
});
it("keeps only the devices that offer the automation", async () => {
const offers = {
device2: [
trigger({
device_id: "device2",
type: "turned_on",
entity_id: "regid1",
}),
],
device3: [
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid2",
}),
],
};
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) => Promise.resolve(offers[deviceId])
)
).toEqual(["device2"]);
});
it("drops a device whose automations cannot be listed", async () => {
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) =>
deviceId === "device2"
? Promise.reject(new Error("unknown device"))
: Promise.resolve([
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid1",
}),
])
)
).toEqual(["device3"]);
});
});
+165
View File
@@ -0,0 +1,165 @@
import type { Connection } from "home-assistant-js-websocket";
import { beforeEach, describe, expect, it, vi } from "vitest";
// MapLibre hands tile URLs to a worker, which has no document to resolve a
// relative URL against. Measured: without absolute URLs nothing loads at all -
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
// so this is worth pinning down.
const connectionWith = (...tokens: string[]) => {
let call = 0;
const listeners: Record<string, () => void> = {};
return {
sendMessagePromise: vi.fn(async () => ({
token: tokens[Math.min(call++, tokens.length - 1)],
})),
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event] = cb;
}),
listeners,
} as unknown as Connection & { listeners: Record<string, () => void> };
};
const load = async () => {
vi.resetModules();
return import("../../src/data/map_tiles");
};
describe("withMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("makes relative URLs absolute", async () => {
const { withMapTilesToken } = await load();
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
`${location.origin}/api/map_tiles/tilejson.json`
);
});
it("adds the token to proxy URLs once there is one", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
expect(url.searchParams.get("token")).toBe("abc123");
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
});
it("leaves anything outside the proxy alone", async () => {
const { ensureMapTilesToken, withMapTilesToken } = await load();
await ensureMapTilesToken(connectionWith("abc123"));
const url = new URL(
withMapTilesToken("https://example.com/tiles/1/0/0.png")
);
expect(url.searchParams.get("token")).toBeNull();
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
});
it("still returns an absolute URL when there is no token", async () => {
const { withMapTilesToken } = await load();
expect(
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
).toBe(
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
);
});
});
describe("ensureMapTilesToken", () => {
beforeEach(() => {
vi.resetModules();
});
it("asks the backend once and reuses the answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = connectionWith("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(await ensureMapTilesToken(connection)).toBe("abc123");
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
});
// A backend without the proxy must not hold the map hostage while the
// retries run; the map falls back to loading nothing rather than to nothing
// being drawn at all.
it("gives up quickly on a backend that does not answer", async () => {
const { ensureMapTilesToken } = await load();
const connection = {
sendMessagePromise: vi.fn(async () => {
throw new Error("unknown command");
}),
} as unknown as Connection;
const started = Date.now();
expect(await ensureMapTilesToken(connection)).toBeUndefined();
expect(Date.now() - started).toBeLessThan(3000);
});
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const listener = vi.fn();
subscribeMapTilesToken(listener);
await ensureMapTilesToken(connectionWith("abc123"));
expect(listener).toHaveBeenCalledWith("abc123");
});
});
// A token can expire before the refresh interval comes round, so recovery
// hangs on the reconnect and on retrying a refused request.
describe("recovering a stale token", () => {
beforeEach(() => {
vi.resetModules();
});
it("fetches a new token when the connection comes back", async () => {
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
const connection = connectionWith("first", "second") as Connection & {
listeners: Record<string, () => void>;
};
await ensureMapTilesToken(connection);
const listener = vi.fn();
subscribeMapTilesToken(listener);
connection.listeners.ready();
await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second"));
});
it("asks once for a refresh however many tiles were refused", async () => {
const { ensureMapTilesToken, refreshMapTilesToken } = await load();
const connection = connectionWith("first", "second");
await ensureMapTilesToken(connection);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
await Promise.all([
refreshMapTilesToken(),
refreshMapTilesToken(),
refreshMapTilesToken(),
]);
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2);
});
it("keeps the old token when the refresh fails", async () => {
const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } =
await load();
const connection = connectionWith("first");
await ensureMapTilesToken(connection);
vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce(
new Error("disconnected")
);
await refreshMapTilesToken();
expect(
new URL(
withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")
).searchParams.get("token")
).toBe("first");
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
RESOLVE_CACHE_TIME,
resolveMediaSourceWithCache,
} from "../../src/data/media_source";
import type { HomeAssistant } from "../../src/types";
const CONTENT_ID = "media-source://image_upload/background";
const OTHER_CONTENT_ID = "media-source://image_upload/other";
// Core signs every resolution with a fresh timestamp, so an uncached resolve of
// the same id yields a different url each time. The mock reproduces that: the
// urls only stay equal if the resolution itself was reused.
const mockHass = () => {
let signature = 0;
return {
callWS: vi.fn(({ media_content_id }: { media_content_id: string }) => {
signature += 1;
return Promise.resolve({
url: `/api/image/serve/${media_content_id.split("/").pop()}/original?authSig=sig${signature}`,
mime_type: "image/jpeg",
});
}),
} as unknown as HomeAssistant;
};
describe("resolveMediaSourceWithCache", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns the same url when the same content id is resolved again", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("shares a single request between concurrent callers", async () => {
const hass = mockHass();
const [first, second] = await Promise.all([
resolveMediaSourceWithCache(hass, CONTENT_ID),
resolveMediaSourceWithCache(hass, CONTENT_ID),
]);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("resolves each content id to its own url", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const other = await resolveMediaSourceWithCache(hass, OTHER_CONTENT_ID);
expect(first.url).toContain("/background/");
expect(other.url).toContain("/other/");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("keeps returning the same url when hass is updated", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
// State updates replace hass with a shallow copy
const second = await resolveMediaSourceWithCache(
{ ...hass } as HomeAssistant,
CONTENT_ID
);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("does not cache failures", async () => {
const hass = mockHass();
vi.mocked(hass.callWS).mockRejectedValueOnce(new Error("unresolvable"));
await expect(
resolveMediaSourceWithCache(hass, CONTENT_ID)
).rejects.toThrowError("unresolvable");
const retried = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(retried.url).toContain("authSig=");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("resolves a fresh url once the cached one is about to expire", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
vi.advanceTimersByTime(RESOLVE_CACHE_TIME);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).not.toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("caches for less than the 24 hour signature validity", () => {
expect(RESOLVE_CACHE_TIME).toBeLessThan(24 * 60 * 60 * 1000);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
import type { StatisticsMetaData } from "../../src/data/recorder";
const createHass = (callWS = vi.fn()) =>
({
callWS,
}) as unknown as Pick<HomeAssistant, "callWS">;
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("getStatisticIds", () => {
it("shares concurrent requests for the same statistic type", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const hass = createHass(callWS);
const first = getStatisticIds(hass);
const second = getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("works with callers that only expose callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const api = { callWS } as Pick<HomeAssistant, "callWS">;
const first = getStatisticIds(api);
const second = getStatisticIds(api);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("shares requests across callers with the same callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const firstHass = createHass(callWS);
const secondHass = createHass(callWS);
const first = getStatisticIds(firstHass);
const second = getStatisticIds(secondHass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("does not share requests for different statistic types", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await Promise.all([
getStatisticIds(hass),
getStatisticIds(hass, "mean"),
getStatisticIds(hass, "sum"),
]);
expect(callWS).toHaveBeenCalledTimes(3);
expect(callWS).toHaveBeenNthCalledWith(1, {
type: "recorder/list_statistic_ids",
statistic_type: undefined,
});
expect(callWS).toHaveBeenNthCalledWith(2, {
type: "recorder/list_statistic_ids",
statistic_type: "mean",
});
expect(callWS).toHaveBeenNthCalledWith(3, {
type: "recorder/list_statistic_ids",
statistic_type: "sum",
});
});
it("does not share requests across different callWS owners", async () => {
const firstRequest = deferred<StatisticsMetaData[]>();
const secondRequest = deferred<StatisticsMetaData[]>();
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
const first = getStatisticIds(createHass(firstCallWS));
const second = getStatisticIds(createHass(secondCallWS));
expect(firstCallWS).toHaveBeenCalledTimes(1);
expect(secondCallWS).toHaveBeenCalledTimes(1);
expect(second).not.toBe(first);
firstRequest.resolve([]);
secondRequest.resolve([]);
await Promise.all([first, second]);
});
it("fetches again after the previous request settles", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await getStatisticIds(hass, "sum");
await getStatisticIds(hass, "sum");
expect(callWS).toHaveBeenCalledTimes(2);
});
it("retries after a failed request", async () => {
const callWS = vi
.fn()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce([]);
const hass = createHass(callWS);
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
await getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(2);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { extractFromTarget } from "../../src/data/target";
type CallWS = Parameters<typeof extractFromTarget>[0];
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
describe("extractFromTarget in-flight sharing", () => {
it("shares identical concurrent requests", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target),
extractFromTarget(ws, target),
]);
expect(ws).toHaveBeenCalledTimes(1);
});
it("does not share different targets", async () => {
const ws = callWS();
await Promise.all([
extractFromTarget(ws, { area_id: ["bathroom"] }),
extractFromTarget(ws, { area_id: ["kitchen"] }),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different expandGroup values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false),
extractFromTarget(ws, target, true),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different primaryEntitiesOnly values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false, true),
extractFromTarget(ws, target, false, false),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share between different callWS owners", async () => {
const first = callWS();
const second = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(first, target),
extractFromTarget(second, target),
]);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("fetches again after the request settles", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await extractFromTarget(ws, target);
await extractFromTarget(ws, target);
expect(ws).toHaveBeenCalledTimes(2);
});
});
+7 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import { nextRender } from "../../../src/common/util/render-status";
import type {
FormDialogData,
FormDialogParams,
@@ -146,8 +147,13 @@ const submit = (dialog: DialogForm) =>
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
afterEach(async () => {
mockForm.delayedTag = undefined;
document.body.querySelectorAll("dialog-form").forEach((el) => {
(el as DialogForm).closeDialog();
});
// Drain the fire-and-forget focus restore before jsdom teardown.
await nextRender();
document.body.replaceChildren();
vi.clearAllMocks();
});
+89 -24
View File
@@ -14,6 +14,7 @@ import {
defineRouteSmokeTests,
ensureAppSidebarPanelVisible,
goToPanel,
openMoreInfoDialog,
} from "./app/src/helpers";
import {
expectNoPageErrors,
@@ -370,34 +371,16 @@ test.describe("Light more-info dialog", () => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
const dialog = page.locator("ha-more-info-dialog");
// Fire the standard hass-more-info event from the app root with an
// explicit view. The HA shell opens ha-more-info-dialog on the requested
// view directly, so the test does not depend on the admin/demo-hidden
// header controls.
//
// The event is one-shot: if it lands before the shell's hass-more-info
// listener is attached it is silently dropped. Re-dispatching is
// idempotent (showDialog just resets the dialog to the requested view),
// so poll the dispatch until the requested view actually renders.
await expect(async () => {
await page.evaluate((v) => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light", view: v },
bubbles: true,
composed: true,
})
);
}, view);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(element)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
const dialog = await openMoreInfoDialog(
page,
"light.test_light",
view,
element
);
// Each view should render its own characteristic content, not just an
// empty shell.
@@ -464,6 +447,88 @@ test.describe("Weather more-info deep link", () => {
});
});
test.describe("More-info dialog URL cleanup", () => {
test("strips the deep-link params on a plain close", async ({ page }) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
await dialog.getByRole("button", { name: "Close" }).click();
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
await expect(page).not.toHaveURL(/more-info-entity-id/);
await expect(page).toHaveURL(/#\/config\/dashboard/);
expectNoPageErrors(errors);
});
test.describe("when navigation closes the dialog", () => {
// --ha-dialog-hide-duration only applies in dialog mode; the bottom sheet
// hardcodes its animation duration, so pin a desktop viewport on every
// project to keep the slow-close setup below effective.
test.use({ viewport: { width: 1280, height: 800 } });
test("keeps the new URL when navigation outpaces the close transition", async ({
page,
}) => {
const errors = trackPageErrors(page);
// An exact route so no default-page redirect races the dialog open.
await goToPanel(page, "/?scenario=light-more-info#/config/dashboard");
const dialog = await openMoreInfoDialog(page, "light.test_light");
await expect(page).toHaveURL(/more-info-entity-id=light\.test_light/);
// Make the hide transition outlast navigate()'s dialog-close wait so
// the navigation commits its URL while the dialog is still closing,
// like on a slow device or with a long themed animation.
await page.evaluate(() => {
document.documentElement.style.setProperty(
"--ha-dialog-hide-duration",
"1200ms"
);
});
// Navigate through a synthetic same-origin link: the dialog scrim
// blocks real link clicks and the dialog's own edit/device actions are
// hidden in the demo build, while navigate() closes open dialogs the
// same way for all of them.
await page.evaluate(() => {
const anchor = document.createElement("a");
anchor.href = "/history";
document.body.append(anchor);
anchor.click();
anchor.remove();
});
await expect(page).toHaveURL(/#\/history/, { timeout: QUICK_TIMEOUT });
// The navigation must commit while the dialog is still closing,
// otherwise this test no longer covers the regression.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(1);
// The dialog re-renders empty once its close cleanup has run.
await expect(dialog.locator("ha-adaptive-dialog")).toHaveCount(0, {
timeout: QUICK_TIMEOUT,
});
// The cleanup must not rewrite the URL back to the pre-dialog page.
await expect(page).toHaveURL(/#\/history/);
await expect(page).not.toHaveURL(/more-info-entity-id/);
expectNoPageErrors(errors);
});
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
+32
View File
@@ -27,6 +27,38 @@ export async function goToPanel(page: Page, path: string) {
]);
}
// The hass-more-info event is one-shot: if it lands before the shell's
// listener is attached it is silently dropped. Re-dispatching is idempotent
// (showDialog just resets the dialog to the requested view), so poll the
// dispatch until the requested view actually renders.
export async function openMoreInfoDialog(
page: Page,
entityId: string,
view?: string,
readyElement = "ha-more-info-info"
) {
const dialog = page.locator("ha-more-info-dialog");
await expect(async () => {
await page.evaluate(
(detail) => {
document.querySelector("ha-test")?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail,
bubbles: true,
composed: true,
})
);
},
view ? { entityId, view } : { entityId }
);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(readyElement)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
return dialog;
}
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
+8
View File
@@ -100,6 +100,7 @@ const commandResults: Record<string, unknown> = {
},
"lovelace/info": { resource_mode: "storage" },
"lovelace/resources": [],
"map_tiles/access_token": { token: "map-tiles-token" },
"recorder/info": {
migration_in_progress: false,
migration_is_live: false,
@@ -177,6 +178,13 @@ export async function setupOnboardingMocks(
): Promise<OnboardingCalls> {
const calls: OnboardingCalls = { tokenRequests: [] };
// The location step shows a map, which asks core's tile proxy for tiles core
// is not running here. Answer them so the outcome does not depend on what the
// dev server does with an unknown /api path.
await page.route("**/api/map_tiles/**", (route) =>
route.fulfill({ status: 404, body: "" })
);
await page.route("**/api/onboarding**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
+197
View File
@@ -120,6 +120,50 @@ class RaceRouter extends HassRouterPage {
}
}
class PathPanel extends HTMLElement {
public itemId = "";
}
class DashPanel extends HTMLElement {}
class PathRouter extends HassRouterPage {
public editLoads = 0;
protected routerOptions: RouterOptions = {
routes: {
dashboard: { tag: "test-dash-panel", cache: true },
edit: {
tag: "test-path-panel",
cache: true,
itemId: true,
load: () => {
this.editLoads += 1;
return Promise.resolve();
},
},
view: { tag: "test-path-panel", cache: true },
},
};
protected updatePageEl(el: PathPanel) {
if (this._currentPage === "edit" || this._currentPage === "view") {
el.itemId = this.routeTail.path.slice(1);
}
}
}
class ParentRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
routes: {
automation: { tag: "test-path-router" },
},
};
protected updatePageEl(el: PathRouter) {
el.route = this.routeTail;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
@@ -129,6 +173,10 @@ customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
customElements.define("test-path-panel", PathPanel);
customElements.define("test-dash-panel", DashPanel);
customElements.define("test-path-router", PathRouter);
customElements.define("test-parent-router", ParentRouter);
let router: TestRouter | undefined;
@@ -294,6 +342,151 @@ describe("HassRouterPage update propagation during load", () => {
});
});
describe("HassRouterPage item ids", () => {
it("recreates the page when the item id changes", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("a");
expect(element.editLoads).toBe(1);
element.route = { prefix: "/config/automation", path: "/edit/b" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("b");
expect(first.isConnected).toBe(false);
expect(element.editLoads).toBe(2);
element.remove();
});
it("does not cache a page that has an item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("a");
element.remove();
});
it("still caches pages that have no item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/dashboard" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as DashPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
element.remove();
});
it("keeps the same page when the item id is unchanged", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("a");
element.remove();
});
it("reuses a page with a one-segment tail when itemId is not set", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/lovelace", path: "/view/0" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("0");
element.route = { prefix: "/lovelace", path: "/view/1" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("1");
element.remove();
});
it("does not recreate a nested router when opening an item from the dashboard", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/dashboard" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const dashboard = nested.lastElementChild as DashPanel;
expect(dashboard).toBeInstanceOf(DashPanel);
element.route = { prefix: "/config", path: "/automation/edit/a" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const editor = nested.lastElementChild as PathPanel;
expect(editor.itemId).toBe("a");
element.remove();
});
it("does not recreate a nested router when only its child's id changes", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/edit/a" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const firstLeaf = nested.lastElementChild as PathPanel;
expect(firstLeaf.itemId).toBe("a");
element.route = { prefix: "/config", path: "/automation/edit/b" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const secondLeaf = nested.lastElementChild as PathPanel;
expect(secondLeaf).not.toBe(firstLeaf);
expect(secondLeaf.itemId).toBe("b");
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
@@ -306,5 +499,9 @@ declare global {
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
"test-path-panel": PathPanel;
"test-dash-panel": DashPanel;
"test-path-router": PathRouter;
"test-parent-router": ParentRouter;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { navigate } from "../../src/common/navigate";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
interface TestState {
name: string;
}
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
PreventUnsavedMixin(LitElement)
) {
public promptResponse = true;
public promptCalls = 0;
public initialize(value: TestState) {
this._initDirtyTracking({ type: "shallow" }, value);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
protected async promptDiscardChanges(): Promise<boolean> {
this.promptCalls++;
return this.promptResponse;
}
}
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
declare global {
interface HTMLElementTagNameMap {
"test-prevent-unsaved": TestPreventUnsaved;
}
}
const setEntry = (path: string) => {
window.history.replaceState(null, "", path);
};
const mountClean = async () => {
const element = document.createElement("test-prevent-unsaved");
document.body.append(element);
element.initialize({ name: "Kitchen" });
await element.updateComplete;
return element;
};
const mountDirty = async () => {
const element = await mountClean();
element.setValue({ name: "Bedroom" });
await element.updateComplete;
return element;
};
describe("PreventUnsavedMixin", () => {
beforeEach(() => {
setEntry("/config/automation/edit/1234");
});
afterEach(() => {
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
element.remove();
});
});
it("blocks navigation while dirty when the prompt is declined", async () => {
const element = await mountDirty();
element.promptResponse = false;
expect(await navigate("/config/scene/dashboard")).toBe(false);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
});
it("navigates while dirty when the prompt is confirmed", async () => {
const element = await mountDirty();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt while clean", async () => {
const element = await mountClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt after changes are marked clean, before the next render", async () => {
const element = await mountDirty();
element.markClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("stops guarding after being disconnected", async () => {
const element = await mountDirty();
element.promptResponse = false;
element.remove();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("arms beforeunload only while dirty", async () => {
const element = await mountDirty();
let event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
element.setValue({ name: "Kitchen" });
await element.updateComplete;
event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";
import { goBack, navigate } from "../../../../src/common/navigate";
import type * as NavigateModule from "../../../../src/common/navigate";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import { createMockHass } from "../../../fixtures/hass";
vi.mock("../../../../src/common/navigate", async (importOriginal) => {
const actual = await importOriginal<typeof NavigateModule>();
return {
...actual,
goBack: vi.fn(),
navigate: vi.fn(),
};
});
vi.mock("../../../../src/dialogs/generic/show-dialog-box", () => ({
showAlertDialog: vi.fn(async () => undefined),
showConfirmationDialog: vi.fn(async () => true),
}));
describe("automation editor disconnected load", () => {
test("ignores a config fetch that 404s after the editor is disconnected", async () => {
const el = document.createElement(
"ha-automation-editor"
) as HaAutomationEditor;
const hass = createMockHass();
let rejectLoad!: (reason: { status_code: number }) => void;
(hass as any).callApi = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectLoad = reject;
})
);
el.hass = hass;
el.automations = [];
let connected = true;
Object.defineProperty(el, "isConnected", {
configurable: true,
get: () => connected,
});
const load = (el as any).loadConfig("missing");
connected = false;
rejectLoad({ status_code: 404 });
await load;
expect(goBack).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
/**
* Protects the energy usage graph from adding an empty combined Grid
* legend item for single-source + battery setups, while still combining
* sources when multiple grid imports share a battery-charging period.
*/
import { assert, describe, it } from "vitest";
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
const t = 1_700_000_000_000;
describe("buildCombinedUsedGrid", () => {
it("does not add a combined series for a single grid source charging a battery", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
});
it("combines overlapping grid sources and removes per-source points", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 6 },
"sensor.grid_import_b": { [t]: 4 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.deepEqual(result, { [t]: 7 });
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
});
it("does not add a combined series when battery is present but not charging from grid", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
});
});
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import "../../../../src/panels/lovelace/views/hui-view-background";
import type { HUIViewBackground } from "../../../../src/panels/lovelace/views/hui-view-background";
import type { LovelaceViewBackgroundConfig } from "../../../../src/data/lovelace/config/view";
import type { HomeAssistant } from "../../../../src/types";
const IMAGE_A = "media-source://image_upload/a";
const IMAGE_B = "media-source://image_upload/b";
// Resolutions are answered by hand so a slow one can land after a later,
// already-resolved one — the ordering a cache hit makes easy to hit.
const deferredHass = () => {
const pending = new Map<string, (url: string) => void>();
const hass = {
callWS: vi.fn(
({ media_content_id }: { media_content_id: string }) =>
new Promise((resolve) => {
pending.set(media_content_id, (url: string) =>
resolve({ url, mime_type: "image/jpeg" })
);
})
),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
return { hass, pending };
};
let elements: HUIViewBackground[] = [];
const mount = async (
hass: HomeAssistant,
background: string | LovelaceViewBackgroundConfig
) => {
const el = document.createElement("hui-view-background") as HUIViewBackground;
el.hass = hass;
el.background = background;
document.body.appendChild(el);
elements.push(el);
await el.updateComplete;
return el;
};
const setBackground = async (
el: HUIViewBackground,
background: string | LovelaceViewBackgroundConfig
) => {
el.background = background;
await el.updateComplete;
};
// Let the resolve chain drain before checking what was applied
const settle = async (el: HUIViewBackground) => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
await el.updateComplete;
};
const imageBackground = (
mediaContentId: string
): LovelaceViewBackgroundConfig => ({
image: { media_content_id: mediaContentId },
});
const backgroundUrl = (el: HUIViewBackground) =>
el.style.getPropertyValue("--view-background");
afterEach(() => {
elements.forEach((el) => el.remove());
elements = [];
vi.restoreAllMocks();
});
describe("hui-view-background", () => {
it("applies the resolved url of a media source background", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/a.jpg?authSig=a");
});
it("ignores a resolution that arrives after the background changed", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
await setBackground(el, imageBackground(IMAGE_B));
// B resolves first, then the stale A resolution lands
pending.get(IMAGE_B)!("/b.jpg?authSig=b");
await settle(el);
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/b.jpg?authSig=b");
expect(backgroundUrl(el)).not.toContain("/a.jpg");
});
it("keeps a plain css background untouched", async () => {
const { hass } = deferredHass();
const el = await mount(hass, "#3f51b5");
expect(hass.callWS).not.toHaveBeenCalled();
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("clears the resolved image when the background is replaced by a color", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
await setBackground(el, "#3f51b5");
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("falls back to the theme background when resolving fails", async () => {
const hass = {
callWS: vi.fn().mockRejectedValue(new Error("unresolvable")),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
const el = await mount(hass, imageBackground(IMAGE_A));
await settle(el);
expect(backgroundUrl(el)).toBe("");
});
});
+273 -20
View File
@@ -3495,6 +3495,117 @@ __metadata:
languageName: node
linkType: hard
"@mapbox/jsonlint-lines-primitives@npm:^2.0.2, @mapbox/jsonlint-lines-primitives@npm:~2.0.2":
version: 2.0.3
resolution: "@mapbox/jsonlint-lines-primitives@npm:2.0.3"
checksum: 10/99404df11b1840153c910154c238f62ba0ea7cfab6aa7ed3bad1e522d31b746bfad78b23e6ee4887196acec76759f1863b5cb4f5e2d95d0dc2a8ea73091c78f6
languageName: node
linkType: hard
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
version: 1.1.0
resolution: "@mapbox/point-geometry@npm:1.1.0"
checksum: 10/1e649be5c6c83584fae9e043a398c61ad56f81e2ae5334deeb4048884ed1f2a5dc7e727e1757e3159ebf12e6cb1f5b059519078df8d16452bf7c7ab26323eb4a
languageName: node
linkType: hard
"@mapbox/tiny-sdf@npm:^2.1.0":
version: 2.2.0
resolution: "@mapbox/tiny-sdf@npm:2.2.0"
checksum: 10/8a6c2664cc6d054155e6812bce41cbb7f2b86d2564adce0aebaadd141a827b78209bc026cd9d4d605095df5556274fb541f520222d19eb2b68d65156db9819d9
languageName: node
linkType: hard
"@mapbox/unitbezier@npm:^0.0.1":
version: 0.0.1
resolution: "@mapbox/unitbezier@npm:0.0.1"
checksum: 10/bf104c85dbff37bf47d3217d9457a3abbf23714f78fefadea64e56bdc7c538491b626166809ef28db134f09baccd6ca3df6988a6422df90d8d0c9a23b0686043
languageName: node
linkType: hard
"@mapbox/unitbezier@npm:^1.0.0":
version: 1.0.0
resolution: "@mapbox/unitbezier@npm:1.0.0"
checksum: 10/41371f6edf0c643da0867453c18e1f7070d4c342339817495075bbb841d5283589a1b5492da6f2eb617803e0d6efc2e5354080b302b0eb84b13b3dcd64991230
languageName: node
linkType: hard
"@mapbox/vector-tile@npm:^2.0.4":
version: 2.0.5
resolution: "@mapbox/vector-tile@npm:2.0.5"
dependencies:
"@mapbox/point-geometry": "npm:~1.1.0"
"@types/geojson": "npm:^7946.0.16"
pbf: "npm:^4.0.2"
checksum: 10/ca788f6c34bc699033f02f6f9a8fc2a4539cfb6c85bdd5a3a143c31698ae8865e99e3b51a4cf11a2070a14c435cba2ec8ca1bcad4eeac0cd678b972a7736c402
languageName: node
linkType: hard
"@mapbox/whoots-js@npm:^3.1.0":
version: 3.1.0
resolution: "@mapbox/whoots-js@npm:3.1.0"
checksum: 10/c1837c04effd205b207f441356d952eae7e8aad6c58f7c4900de50318c2147cf175936fc9434f20dfa409f9e6a78ec604d61e70c1c20572db0cc7655fbb65f50
languageName: node
linkType: hard
"@maplibre/geojson-vt@npm:^6.1.0":
version: 6.1.1
resolution: "@maplibre/geojson-vt@npm:6.1.1"
dependencies:
kdbush: "npm:^4.1.0"
checksum: 10/61c6ca6dc49d799f22dd98b450d48cf172cacf886edbe73e195b0526fe0bad9c42521792f1c7f4361da0069f604517941b71def7f4745fa945c9635a1eb3e250
languageName: node
linkType: hard
"@maplibre/maplibre-gl-leaflet@npm:0.1.4":
version: 0.1.4
resolution: "@maplibre/maplibre-gl-leaflet@npm:0.1.4"
peerDependencies:
"@types/leaflet": ^1.9.0
leaflet: ^1.9.3
maplibre-gl: ^2.4.0 || ^3.3.1 || ^4.3.2 || ^5.0.0 || ^6.0.0
checksum: 10/9a6d175eaa19d51505aaa2cbd16b78756530cb83a6b770e9a9859df566b9cce38b3ca904423bfde1798e539c6a1e5ff37cc4502e896c829dc219bece8b0b1ee6
languageName: node
linkType: hard
"@maplibre/maplibre-gl-style-spec@npm:^24.8.1":
version: 24.10.0
resolution: "@maplibre/maplibre-gl-style-spec@npm:24.10.0"
dependencies:
"@mapbox/jsonlint-lines-primitives": "npm:~2.0.2"
"@mapbox/unitbezier": "npm:^1.0.0"
json-stringify-pretty-compact: "npm:^4.0.0"
minimist: "npm:^1.2.8"
quickselect: "npm:^3.0.0"
tinyqueue: "npm:^3.0.0"
bin:
gl-style-format: dist/gl-style-format.mjs
gl-style-migrate: dist/gl-style-migrate.mjs
gl-style-validate: dist/gl-style-validate.mjs
checksum: 10/7d580c71a7978ffd6c93a95a9178eb55fa0777eb63f1954afec58934e940a7d1432ecd51e98b725e01ae97298b7a60d6a2a19a863952fad45f97ae609ad0d886
languageName: node
linkType: hard
"@maplibre/mlt@npm:^1.1.8":
version: 1.2.0
resolution: "@maplibre/mlt@npm:1.2.0"
dependencies:
"@mapbox/point-geometry": "npm:^1.1.0"
checksum: 10/9bc3d30f78dc2090ac80634e08848bf454daed888fcbcace7e1c1c9b8d1db6c5421376f681dffe90bb4274488753775f36e76a1568ba724e64a0ca59207c04c4
languageName: node
linkType: hard
"@maplibre/vt-pbf@npm:^4.3.0":
version: 4.3.2
resolution: "@maplibre/vt-pbf@npm:4.3.2"
dependencies:
"@mapbox/point-geometry": "npm:^1.1.0"
"@types/geojson": "npm:^7946.0.16"
pbf: "npm:^5.1.0"
checksum: 10/731a6a094964bc004b51451096cdb5ff65ca60f1c443e638f0e316bb1b24431e67988531bb2f9cb854d3ec31597f32fbbf7de550245efac3bfb1a0cc1d33b567
languageName: node
linkType: hard
"@marijn/find-cluster-break@npm:^1.0.0":
version: 1.0.3
resolution: "@marijn/find-cluster-break@npm:1.0.3"
@@ -5257,7 +5368,7 @@ __metadata:
languageName: node
linkType: hard
"@sveltejs/acorn-typescript@npm:^1.0.10":
"@sveltejs/acorn-typescript@npm:^1.0.13":
version: 1.0.13
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
peerDependencies:
@@ -5650,7 +5761,7 @@ __metadata:
languageName: node
linkType: hard
"@types/geojson@npm:*":
"@types/geojson@npm:*, @types/geojson@npm:^7946.0.16":
version: 7946.0.16
resolution: "@types/geojson@npm:7946.0.16"
checksum: 10/34d07421bdd60e7b99fa265441d17ac6e9aef48e3ce22d04324127d0de1daf7fbaa0bd3be1cece2092eb6995f21da84afa5231e24621a2910ff7340bc98f496f
@@ -6344,6 +6455,15 @@ __metadata:
languageName: node
linkType: hard
"@versatiles/style@npm:5.13.1":
version: 5.13.1
resolution: "@versatiles/style@npm:5.13.1"
dependencies:
brace-expansion: "npm:^5.0.9"
checksum: 10/046be455df89f55bc6a8b1b63a01169f68e701799249761c49523c6098b871d9cdb5b0c8a6a1a15503eee17d89f6fb9f0421eb48cf71d70dfa2cee2900a5b25a
languageName: node
linkType: hard
"@vibrant/color@npm:4.0.4, @vibrant/color@npm:^4.0.4":
version: 4.0.4
resolution: "@vibrant/color@npm:4.0.4"
@@ -6667,7 +6787,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
version: 8.18.0
resolution: "acorn@npm:8.18.0"
bin:
@@ -7312,7 +7432,7 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.8":
"brace-expansion@npm:^5.0.8, brace-expansion@npm:^5.0.9":
version: 5.0.9
resolution: "brace-expansion@npm:5.0.9"
dependencies:
@@ -8422,6 +8542,13 @@ __metadata:
languageName: node
linkType: hard
"earcut@npm:^3.0.2":
version: 3.2.3
resolution: "earcut@npm:3.2.3"
checksum: 10/3c3c6bfc214060e6df83da1da413709cf15c77139b0e094a71e6e960ffce033c88586c1e880496ea0a39d891df74a065ccfe24c18f426898ea07a51e0fc0a446
languageName: node
linkType: hard
"eastasianwidth@npm:^0.2.0":
version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0"
@@ -9672,6 +9799,13 @@ __metadata:
languageName: node
linkType: hard
"gl-matrix@npm:^3.4.4":
version: 3.4.4
resolution: "gl-matrix@npm:3.4.4"
checksum: 10/0a19a881fbfa2cdcff2b5ece0f62041d17e55665393349653e8742a20e43d4516239c68ac6798baa7c35b0c7bd6c9226e70e7824af162228d471eba358a03090
languageName: node
linkType: hard
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
@@ -10014,6 +10148,7 @@ __metadata:
"@lit/reactive-element": "npm:2.1.2"
"@lit/task": "npm:1.0.3"
"@lokalise/node-api": "npm:16.3.0"
"@maplibre/maplibre-gl-leaflet": "npm:0.1.4"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"@material/web": "npm:2.5.0"
@@ -10046,6 +10181,7 @@ __metadata:
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@versatiles/style": "npm:5.13.1"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
@@ -10109,9 +10245,10 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
minify-literals: "npm:2.2.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
@@ -10190,9 +10327,9 @@ __metadata:
languageName: node
linkType: hard
"html-minifier-next@npm:^7.0.0":
version: 7.5.3
resolution: "html-minifier-next@npm:7.5.3"
"html-minifier-next@npm:^8.1.0":
version: 8.1.0
resolution: "html-minifier-next@npm:8.1.0"
dependencies:
commander: "npm:^15.0.0"
entities: "npm:^8.0.0"
@@ -10207,7 +10344,7 @@ __metadata:
bin:
hmn: cli.js
html-minifier-next: cli.js
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
languageName: node
linkType: hard
@@ -11161,6 +11298,13 @@ __metadata:
languageName: node
linkType: hard
"json-stringify-pretty-compact@npm:^4.0.0":
version: 4.0.0
resolution: "json-stringify-pretty-compact@npm:4.0.0"
checksum: 10/a10d5c423e467872994a49c5c1b56b073f277ce02d899cf567fc625f3783b89406bee6408bfb3b4bdeeff509b6a562f5259227e26754a6186f721809ca895f0c
languageName: node
linkType: hard
"json-with-bigint@npm:^3.5.3":
version: 3.5.11
resolution: "json-with-bigint@npm:3.5.11"
@@ -11230,6 +11374,13 @@ __metadata:
languageName: node
linkType: hard
"kdbush@npm:^4.0.2, kdbush@npm:^4.1.0":
version: 4.1.0
resolution: "kdbush@npm:4.1.0"
checksum: 10/40d0a1a8e9928fabc3b5f7026b280573e718020c74438a4b5702400f8a6e4c38fb6110c456d6e592f56352da7011cc794ed47195c9c501647bcb16287c1d7dd2
languageName: node
linkType: hard
"keyv@npm:^4.5.4":
version: 4.5.4
resolution: "keyv@npm:4.5.4"
@@ -11449,7 +11600,7 @@ __metadata:
languageName: node
linkType: hard
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
version: 1.33.0
resolution: "lightningcss@npm:1.33.0"
dependencies:
@@ -11703,6 +11854,15 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^1.2.2":
version: 1.2.2
resolution: "magic-string@npm:1.2.2"
dependencies:
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
languageName: node
linkType: hard
"magicast@npm:^0.5.2":
version: 0.5.4
resolution: "magicast@npm:0.5.4"
@@ -11757,6 +11917,33 @@ __metadata:
languageName: node
linkType: hard
"maplibre-gl@npm:5.24.0":
version: 5.24.0
resolution: "maplibre-gl@npm:5.24.0"
dependencies:
"@mapbox/jsonlint-lines-primitives": "npm:^2.0.2"
"@mapbox/point-geometry": "npm:^1.1.0"
"@mapbox/tiny-sdf": "npm:^2.1.0"
"@mapbox/unitbezier": "npm:^0.0.1"
"@mapbox/vector-tile": "npm:^2.0.4"
"@mapbox/whoots-js": "npm:^3.1.0"
"@maplibre/geojson-vt": "npm:^6.1.0"
"@maplibre/maplibre-gl-style-spec": "npm:^24.8.1"
"@maplibre/mlt": "npm:^1.1.8"
"@maplibre/vt-pbf": "npm:^4.3.0"
"@types/geojson": "npm:^7946.0.16"
earcut: "npm:^3.0.2"
gl-matrix: "npm:^3.4.4"
kdbush: "npm:^4.0.2"
murmurhash-js: "npm:^1.0.0"
pbf: "npm:^4.0.1"
potpack: "npm:^2.1.0"
quickselect: "npm:^3.0.0"
tinyqueue: "npm:^3.0.0"
checksum: 10/0bf5bd004e1ae545456407bd42f201913e9e67fb302b7d18804c81dffd0feefad55dfb249514c7ad62e8cea57621777f1061f8187e254a108230e4f7eb4dd8e9
languageName: node
linkType: hard
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
@@ -11880,16 +12067,16 @@ __metadata:
languageName: node
linkType: hard
"minify-literals@npm:2.1.0":
version: 2.1.0
resolution: "minify-literals@npm:2.1.0"
"minify-literals@npm:2.2.0":
version: 2.2.0
resolution: "minify-literals@npm:2.2.0"
dependencies:
"@sveltejs/acorn-typescript": "npm:^1.0.10"
acorn: "npm:^8.17.0"
html-minifier-next: "npm:^7.0.0"
lightningcss: "npm:^1.32.0"
magic-string: "npm:^0.30.21"
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
"@sveltejs/acorn-typescript": "npm:^1.0.13"
acorn: "npm:^8.18.0"
html-minifier-next: "npm:^8.1.0"
lightningcss: "npm:^1.33.0"
magic-string: "npm:^1.2.2"
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
languageName: node
linkType: hard
@@ -11920,7 +12107,7 @@ __metadata:
languageName: node
linkType: hard
"minimist@npm:^1.2.0":
"minimist@npm:^1.2.0, minimist@npm:^1.2.8":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
@@ -12026,6 +12213,13 @@ __metadata:
languageName: node
linkType: hard
"murmurhash-js@npm:^1.0.0":
version: 1.0.0
resolution: "murmurhash-js@npm:1.0.0"
checksum: 10/875a24e0dd7870e51a7f73906e158fb06de50478669629746a35955cb0a00b6bb797f6b5a2884ee4ec4feefb9c5c27b74190f561eb72530ffc1c5d7c5429f49a
languageName: node
linkType: hard
"mute-stdout@npm:^2.0.0":
version: 2.0.0
resolution: "mute-stdout@npm:2.0.0"
@@ -12792,6 +12986,28 @@ __metadata:
languageName: node
linkType: hard
"pbf@npm:^4.0.1, pbf@npm:^4.0.2":
version: 4.0.2
resolution: "pbf@npm:4.0.2"
dependencies:
resolve-protobuf-schema: "npm:^2.1.0"
bin:
pbf: bin/pbf
checksum: 10/a6d60f2f374c51bcb08b1b49d08c01aed31c4e9cfbf43002178e1cf0f63c5f5fc4aa9dd0220948be775e2d0bcc644954b9d1e8dca91466a5160dcc0a0313edd3
languageName: node
linkType: hard
"pbf@npm:^5.1.0":
version: 5.1.2
resolution: "pbf@npm:5.1.2"
dependencies:
resolve-protobuf-schema: "npm:^2.1.0"
bin:
pbf: bin/pbf
checksum: 10/add0663777f352c9432503e5fa18eb5be112a8ee4d32b233b8c7128bebddb013b874748f63d9db1a6f3050d077c70acdafda75fd8d4b66a75af627ec2db4b7a8
languageName: node
linkType: hard
"peek-readable@npm:^4.1.0":
version: 4.1.0
resolution: "peek-readable@npm:4.1.0"
@@ -12925,6 +13141,13 @@ __metadata:
languageName: node
linkType: hard
"potpack@npm:^2.1.0":
version: 2.1.0
resolution: "potpack@npm:2.1.0"
checksum: 10/8b5c07c8569f06cb14a8034de3057129f8c4909837cb892eb6c1cbe79f06ee68f2e9fbc0610599e2e38a29a489e1ed72fbb4059e7ff6f648826fc961d4c0f607
languageName: node
linkType: hard
"preact@npm:~10.12.1":
version: 10.12.1
resolution: "preact@npm:10.12.1"
@@ -13018,6 +13241,13 @@ __metadata:
languageName: node
linkType: hard
"protocol-buffers-schema@npm:^3.3.1":
version: 3.6.1
resolution: "protocol-buffers-schema@npm:3.6.1"
checksum: 10/a7ca74e71227932f903feab9df8ffde80703b656fd024179b3a9439edb3907eca4c78f0cd6f47ad7698e832ae565a8df7585d9cedcc4f043794e6ba8e480cdec
languageName: node
linkType: hard
"punycode@npm:2.3.1, punycode@npm:^2.1.0, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -13061,6 +13291,13 @@ __metadata:
languageName: node
linkType: hard
"quickselect@npm:^3.0.0":
version: 3.0.0
resolution: "quickselect@npm:3.0.0"
checksum: 10/8f72bedb8bb14bce5c3767c55f567bc296fa3ca9d98ba385e3867e434463bc633feee1eddf3dfec17914b7e88feeb08c7b313cf47114a8ff11bf964f77f51cfc
languageName: node
linkType: hard
"range-parser@npm:1.2.0":
version: 1.2.0
resolution: "range-parser@npm:1.2.0"
@@ -13357,6 +13594,15 @@ __metadata:
languageName: node
linkType: hard
"resolve-protobuf-schema@npm:^2.1.0":
version: 2.1.0
resolution: "resolve-protobuf-schema@npm:2.1.0"
dependencies:
protocol-buffers-schema: "npm:^3.3.1"
checksum: 10/88fffab2a3757888884a36f9aa4e24be5186b01820a8c26297dc1ce406b9daf776594926bdf524c2c8e8e5b0aba8ac48362b6584cdecc9a7083215ebca01c599
languageName: node
linkType: hard
"resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.11":
version: 1.22.12
resolution: "resolve@npm:1.22.12"
@@ -14730,6 +14976,13 @@ __metadata:
languageName: node
linkType: hard
"tinyqueue@npm:^3.0.0":
version: 3.0.0
resolution: "tinyqueue@npm:3.0.0"
checksum: 10/44195ae628e98f4de49acefac1fafa63a7f2b5d8a5c23ace6f49917109db3435db8ec9854f87c0d50f8a8c6a73f1526f3941921618a071e4ee1d246afacf69bb
languageName: node
linkType: hard
"tinyrainbow@npm:^3.1.0":
version: 3.1.1
resolution: "tinyrainbow@npm:3.1.1"