Compare commits

..
51 Commits
Author SHA1 Message Date
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
89a467cac7 Update dependency @rspack/core to v2.2.0 (#53872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 07:15:53 +00:00
BrandonandGitHub c29b693038 Make the media selector clear button follow image_upload (#53866) 2026-08-29 09:14:05 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7ab178d045 Update Node.js to v24.20.0 (#53871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:08:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
538523c58b Update dependency generate-license-file to v4.2.4 (#53868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:07:39 +02:00
136cf0c8bf Revamp user settings page & navigation (#52070)
* refactor(profile): split general section into subpages with dashboard navigation

Replace the two-tab profile layout (general/security) with a dashboard-first
navigation structure. The general section is split into three focused subpages
(preferences, localization, browser), each wrapping the original row components
unchanged. A new dashboard page provides a user card (name, owner badge, logout)
and a navigation list linking to all four subpages.

Add ha-navigation-list as a reusable component for page navigation lists.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: remove orphan translation keys after general section split

Drop tabs.general and current_user, which were only used in the now-deleted
ha-profile-section-general.ts and the old profileSections tab config.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): align dashboard layout with Bluetooth panel pattern

Replace .content with .container (padding on wrapper, max-width/margin on
cards) to match the layout convention used in protocol panels like Bluetooth.
Always show the chevron in ha-navigation-list, removing the narrow condition.
Drop the unused narrow prop from ha-navigation-list.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: move ha-config-navigation-list to src/components

Makes the component available outside the config panel. Update import
paths in the two existing config consumers and use it in the profile
dashboard. Remove the now-unused ha-navigation-list (which relied on
ha-md-list/ha-md-list-item).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: security subpage width and card spacing

Use .container > * instead of ha-card to apply max-width and margin,
since the security cards are custom elements whose inner ha-card lives
in their own shadow DOM and is unreachable from the parent stylesheet.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): move language setting to localization subpage

Language is a regional/cultural preference, consistent with the other
settings in localization (time zone, date and time formats).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): rename profile subpage labels

- "User preferences" -> "Appearance"
- "Browser settings" -> "This browser"
- "Mobile app settings" -> "This mobile app"

* feat(profile): surface theme picker on dashboard page

Move ha-pick-theme-row from the Appearance subpage to the profile
dashboard, between the user card and the navigation list. Theme and
dark mode are now accessible directly without navigating to a subpage.

Update Appearance description to reflect its new scope.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): restructure theme row layout and labels

Split dark mode and color pickers into separate ha-settings-row elements
with headings. Move reset button outside the color-row to keep the label
vertically stable when the button appears. Add padding to reset row.
Rename "Reset" to "Reset colors" for clarity.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* add title to theme settings card

* refactor(profile): apply theme row layout improvements to ha-theme-settings

Migrate the dark mode and color pickers layout from ha-pick-theme-row into
ha-theme-settings: use separate ha-settings-row elements with headings for
dark mode and custom colors, move reset button outside as a sibling element.
Add colors label to ThemeSettingsLabels interface.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): migrate theme settings to ha-list-item-base layout

Replace ha-settings-row elements in ha-theme-settings with ha-list-item-base
inside ha-list-base for a more compact appearance. Split color pickers into
individual rows (one per color). Reduce vertical padding to --ha-space-2.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): combine color pickers into single row with label

Merge primary and accent color pickers into one ha-list-item-base row
under a "Custom colors" headline. Restore labels on individual pickers
and increase their min-width to 150px.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(profile): stack theme settings rows vertically in narrow mode

Reflect narrow prop on host and use ::part(base/end) to stack all
ha-list-item-base rows in column layout on narrow screens, preventing
headline truncation from oversized end slot controls.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Fix test:e2e:demo

* fix broken links

* Add my links

* Use s instead of smal for ha-button size

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 21:57:59 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
920c858056 Update dependency @lokalise/node-api to v16.4.0 (#53865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:46 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3d2a4bfa04 Update dependency generate-license-file to v4.2.3 (#53864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:43 +02:00
Paul BotteinandGitHub e2c9b2cb79 Always allow saving vacuum segment mapping (#53862) 2026-08-28 17:21:14 +02:00
Maarten LakerveldandGitHub d38ee185bf Allow negative number entry on iOS when the number selector has no min (#53852)
The iOS workaround from #52925 only applied when the selector had an
explicit negative min. The numeric threshold selector used by the power
triggers passes a number selector without a min, so the digit-only keypad
(without a minus key) was still shown. Treat a missing min as allowing
negatives too.

Also leave inputmode unset instead of forcing "text": on a number input
iOS then shows the Numbers and Punctuation keyboard, which has a minus
key and fits numeric entry better than the full QWERTY keyboard.

Fixes #53747
2026-08-28 16:50:01 +02:00
7e10f50419 Prefer neighbor relationship over raw LQI in ZHA network graph fallback (#53764)
* Prefer neighbor relationship over raw LQI in ZHA network graph fallback

* Create zha-network-data.test.ts

* Fix relative import paths and test connectivity via BFS instead of exact edges

* Add test coverage for extended relationship priority

* Apply suggestions from code review

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

* Just capitalizing relationshipPriority → RELATIONSHIP_PRIORITY in those two spots — nothing else changes.

* Update zha-network-data.ts

* Update zha-network-data.test.ts

* Update zha-network-data.ts

* Update zha-network-data.ts

* Update zha-network-data.test.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 15:47:00 +03:00
noksideandGitHub 070fd96d6c Fix and centralize selector initial value handling (#53729)
* Fix and centralize selector initial value handling

* Fix unsupported selector initial values

* Omit undefined choose child values

* Fix device class and object selector initial values
2026-08-28 15:45:57 +03:00
Maarten LakerveldandGitHub 156840b6ff Fix RTL map labels and add English names to non-Latin ones (#53856)
* Load MapLibre RTL text plugin so Hebrew and Arabic map labels read correctly

The vector base map never registered MapLibre's RTL text plugin, so
right-to-left scripts were shaped left to right and every label came out
reversed. Ship @mapbox/mapbox-gl-rtl-text from /static/map/ alongside the
glyphs (no third-party host) and register it once, lazily, before the
first vector layer is created.

Fixes #53851

* Add the English name to map labels in non-Latin scripts

Before the move to vector tiles the CARTO basemap showed English names
everywhere. The OSM style shows local names, which most users cannot
read in Cyrillic, Arabic, Hebrew or CJK regions. Keep the local name and
add the English one from the tiles under it - in parentheses for street
labels, which cannot break lines. Latin-script names are left alone, so
Köln stays Köln.
2026-08-28 14:44:43 +03:00
5ff7a05ec5 feat: option to show add event button in calendar card (#53055)
* feat: option to show add event button in calendar card

* fix: remove translation file

* refactor: rename show_addfab to show_add_event

* fix: css calendar height

* feat: add event styling options

* Delete translations/backend/en.json

* fix: translation file

* fix: translations file

* fix: no box shadow when button below calendar

* fix: inconsistent narrow mode calculation

* fix: screen reader compability

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix: margin-inline-start for rtl layout support

* fix: use literal union instead of string in editor

* fix: use sentence case

* Apply suggestions from code review

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix: remove unused class

* feat: use visible parameter to toggle editor elements

* refactor: split add-fab into mulitple attributes for ha-full-calendar

* fix: lint type error

* fix: editor defaults

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 14:39:17 +03:00
c3643555ab Respect gas entity display precision in energy dashboard (#53712)
* Respect gas display precision in gas graph

The gas graph previously formatted the total gas consumption using the generic number formatter, which could ignore the display precision  configured on the selected gas entity.

Use the configured gas entity's display precision for both the visible total chip and the total consumption tooltip.

This keeps the gas graph consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use configured gas entity display precision in energy distribution card

The energy distribution card previously formatted gas usage with the generic
energy formatter, which could ignore the display precision configured on the
selected gas entity.

Use the display precision of the configured gas consumption entity when
formatting the gas value in the energy distribution card.

This keeps the displayed value consistent with the entity configuration. For
example, a gas entity configured with 3 decimal places will now display
0.009 m³ instead of being rounded to 0.01 m³.

* Respect gas display precision in energy sources table

The energy sources table previously formatted gas values using the generic number formatter, which could ignore the display precision configured on the selected gas entity.

Use the configured gas entity's display precision for both individual gas source rows and the gas total row.

This keeps gas values consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision in gas graph

The gas graph previously used the display precision of the first configured gas source for its aggregated total.

Use the highest configured display precision across all gas sources instead of special-casing source 0.

This keeps the graph total and tooltip consistent with the greatest configured precision when multiple gas sources are present.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Restore consumption formatting in energy distribution card

* Respect gas display precision in energy sources table

* Respect gas display precision in energy graph

* Respect display precision in formatConsumptionShort

* Pass gas display precision to consumption formatter

Add logic to calculate gas display precision based on available sources.

* Use gas display precision for graph y-axis

* Update distribution card on gas precision changes

* Update sources table on gas precision changes

* Update gas graph when display precision changes

Refresh the gas graph when a gas entity's display precision or unit of measurement changes, ensuring the configured precision is applied without waiting for new energy data.

* Update distribution card on gas unit changes

Refactor gas source display precision check and update unit of measurement comparison.

* Derive gas display precision with a getter

* Fix hass change handling in energy sources table

* Fix hass change handling in gas graph

* Fix hass change handling in energy distribution

* Fix gas unit narrowing in energy distribution

* Fix formatting in gas graph card

Refactor gasDisplayPrecisions assignment for better readability.

* Fix formatting in energy sources table

* Fix Prettier formatting in energy gas cards

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 11:15:12 +00:00
Paul BotteinandGitHub ae2c296272 Remove vacuum battery_level attribute unit (#53855) 2026-08-28 12:42:38 +02:00
1db1c8024e Improve low battery entity lookup (#53850)
* Improve low battery entity lookup

* Apply suggestions from code review

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

* Fix formatting

---------

Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 10:47:44 +02:00
renovate[bot]andGitHub 2f003ca999 Update dependency js-yaml to v5.4.0 (#53853) 2026-08-28 08:58:36 +01:00
Maarten LakerveldandGitHub b1d8a21aaa Don't close drawer when a nested tooltip hides (#53809)
wa-after-hide from a tooltip inside the drawer (e.g. the exact time
tooltip on a persistent notification) bubbled to the wa-drawer listener
and was treated as the drawer closing. Only handle the drawer's own event.

Fixes #52901
2026-08-28 08:45:28 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cde2e66229 Update dependency typescript-eslint to v8.68.0 (#53848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 06:45:08 +02:00
Paulus SchoutsenandGitHub 71d5500ed8 Render camera favorites as camera tiles (#53814) 2026-08-28 00:05:02 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
462321a3c8 Update dependency eslint to v10.9.1 (#53846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
afc0c36dd9 Update dependency marked to v18.0.11 (#53847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:43 +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
100 changed files with 4997 additions and 1431 deletions
+1 -1
View File
@@ -1 +1 @@
24.19.0
24.20.0
+4
View File
@@ -108,6 +108,10 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
}
function copyZXingWasm(staticDir) {
+2 -1
View File
@@ -13,6 +13,7 @@ import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
@@ -195,7 +196,7 @@ const buildMapAssets = async () => {
["light", colorful],
["dark", eclipse],
].map(([name, builder]) => {
const style = useTileJson(name, builder(styleOptions));
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
+42
View File
@@ -0,0 +1,42 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+10 -9
View File
@@ -75,6 +75,7 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@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",
@@ -109,7 +110,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.3.0",
"js-yaml": "5.4.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -117,7 +118,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -149,13 +150,13 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.3.0",
"@lokalise/node-api": "16.4.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.1.10",
"@rspack/core": "2.2.0",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -179,7 +180,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.0",
"eslint": "10.9.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -189,7 +190,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"generate-license-file": "4.2.4",
"glob": "13.0.6",
"globals": "17.11.0",
"gulp": "5.0.1",
@@ -206,7 +207,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",
@@ -216,7 +217,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.68.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
@@ -234,6 +235,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.19.0"
"node": "24.20.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.1"
version = "20260826.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+24 -3
View File
@@ -1,6 +1,6 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { StyleSpecification } from "maplibre-gl";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
@@ -13,6 +13,10 @@ const VECTOR_STYLES = {
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
@@ -71,6 +75,20 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
@@ -211,8 +229,11 @@ export const createBaseLayer = async (
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const { maplibreGL: createLayer } =
await import("@maplibre/maplibre-gl-leaflet");
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
+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];
};
@@ -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,
};
}
+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 {
@@ -1,12 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
+4
View File
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
@@ -1,6 +1,13 @@
import type { Selector } from "../../data/selector";
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -18,7 +25,8 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -33,7 +41,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
const expandableData = computeInitialHaFormData(field.schema, options);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -62,104 +70,11 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
}
}
});
@@ -1,25 +1,89 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
};
@@ -0,0 +1,133 @@
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorInitialValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_INITIAL_VALUES = {
action: () => [],
addon: () => "",
automation_behavior: undefined,
app: undefined,
area: (selector) => (selector.area?.multiple ? [] : ""),
areas_display: undefined,
attribute: () => "",
assist_pipeline: undefined,
boolean: () => false,
choose: (selector) => {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
return {};
}
const childValue = getSelectorInitialValueOrUndefined(
selector.choose.choices[firstChoice].selector
);
return childValue === undefined
? { active_choice: firstChoice }
: { active_choice: firstChoice, [firstChoice]: childValue };
},
color_rgb: () => [0, 0, 0],
condition: () => [],
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: (selector) => selector.country?.countries?.[0],
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
device: (selector) => (selector.device?.multiple ? [] : ""),
device_class: (selector) =>
selector.device_class?.multiple ? [] : undefined,
duration: () => ({
hours: 0,
minutes: 0,
seconds: 0,
}),
entity: (selector) => (selector.entity?.multiple ? [] : ""),
entity_name: undefined,
statistic: undefined,
file: () => "",
floor: undefined,
label: (selector) => (selector.label?.multiple ? [] : ""),
language: (selector) => selector.language?.languages?.[0],
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: (selector) => {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
return type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
period: undefined,
qr_code: undefined,
select: (selector) => {
const select = selector.select;
if (!select?.options.length) {
return undefined;
}
const firstOption = select.options[0];
const value =
typeof firstOption === "string" ? firstOption : firstOption.value;
return select.multiple ? [value] : value;
},
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
backup_location: undefined,
stt: undefined,
target: () => ({}),
template: () => "",
text: (selector) => (selector.text?.multiple ? [] : ""),
time: () => "00:00:00",
icon: () => "",
media: () => ({}),
theme: () => "",
timezone: undefined,
button_toggle: undefined,
trigger: () => [],
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorInitialValues;
export const getSelectorInitialValueOrUndefined = (
selector: Selector
): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
};
export const getSelectorInitialValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const initialValue = SELECTOR_INITIAL_VALUES[type];
if (!initialValue) {
throw new Error(`Selector ${type} not supported in initial form data`);
}
return initialValue(selector as never);
};
@@ -204,7 +204,7 @@ export class HaMediaSelector extends LitElement {
</div>
</ha-card>
${
this.selector.media?.clearable
this.selector.media?.image_upload
? html`<div>
<ha-button
appearance="plain"
@@ -67,15 +67,16 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
// Leaving inputmode unset on a number input gives the "Numbers and
// Punctuation" keyboard there, which does include a minus. Other platforms
// include a minus on their number keypads, so restrict this workaround to
// Safari/WebKit and only when the selector allows negatives: either an
// explicit negative min, or no min at all (e.g. the numeric threshold
// selector used by the power triggers).
const useSafariNegativeKeyboard =
isSafari &&
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
(this.selector.number?.min === undefined || this.selector.number.min < 0);
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
@@ -112,8 +113,8 @@ export class HaNumberSelector extends LitElement {
}
<ha-input
.inputmode=${
useTextInputMode
? "text"
useSafariNegativeKeyboard
? undefined
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
@@ -13,6 +13,7 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -237,10 +238,14 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema: this._schema(this.selector),
data: {},
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -293,7 +298,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "value-changed", { value: "" });
return;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector } from "../../data/selector";
import type { Selector, SelectorType } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
};
} satisfies Record<SelectorType, () => Promise<unknown>>;
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+133 -105
View File
@@ -9,7 +9,8 @@ import {
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-settings-row";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
@@ -25,6 +26,7 @@ export interface ThemeSettingsLabels {
autoMode?: string;
lightMode?: string;
darkMode?: string;
colors?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
@@ -42,7 +44,7 @@ export class HaThemeSettings extends LitElement {
@property() public heading?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -62,98 +64,121 @@ export class HaThemeSettings extends LitElement {
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const showDarkMode =
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme);
return html`
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
${
this.heading
? html`<span slot="heading">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="description">${this.description}</span>`
: nothing
}
<ha-list-base>
${
this.showThemePicker
? html`
<ha-theme-picker
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
<ha-list-item-base>
${
this.heading
? html`<span slot="headline">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="supporting-text"
>${this.description}</span
>`
: nothing
}
<ha-theme-picker
slot="end"
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-list-item-base>
`
: nothing
}
</ha-settings-row>
${
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
${
showDarkMode
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.mode ?? "Theme mode"}</span
>
<ha-radio-group
slot="end"
@change=${this._handleDarkMode}
name="dark_mode"
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
</ha-list-item-base>
`
: nothing
}
${
curTheme === HOME_ASSISTANT_THEME
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.colors ?? "Custom colors"}</span
>
<div slot="end" class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
</div>
</ha-list-item-base>
${
themeSettings?.primaryColor || themeSettings?.accentColor
? html`
<div class="reset-row">
<ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>
</div>
`
: nothing
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
${
curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${
themeSettings?.primaryColor ||
themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>`
: nothing
}
</div>`
: nothing
}
</div>`
: nothing
}
`
: nothing
}
</ha-list-base>
`;
}
@@ -231,33 +256,36 @@ export class HaThemeSettings extends LitElement {
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
ha-list-base {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
ha-theme-picker {
min-width: 150px;
}
.color-pickers {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
gap: var(--ha-space-2);
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
.color-pickers ha-input {
min-width: 150px;
flex: 1;
}
ha-theme-picker {
display: block;
:host([narrow]) ha-list-item-base::part(base) {
flex-direction: column;
align-items: flex-start;
}
:host([narrow]) ha-list-item-base::part(end) {
width: 100%;
}
:host([narrow]) .color-pickers {
width: 100%;
}
.reset-row {
display: flex;
justify-content: flex-end;
padding-inline-end: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
}
`;
}
@@ -564,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
@@ -647,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,
@@ -664,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);
+50 -33
View File
@@ -1553,7 +1553,8 @@ export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string,
targetUnit?: string
targetUnit?: string,
displayPrecision?: number
): string => {
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
@@ -1583,10 +1584,19 @@ export const formatConsumptionShort = (
pickedUnit = units[unitIndex];
}
return (
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
formatNumber(
val,
hass.locale,
displayPrecision !== undefined && pickedUnit === unit
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}
) +
" " +
pickedUnit
);
@@ -1909,20 +1919,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 +1933,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 +2180,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",
});
-3
View File
@@ -50,9 +50,6 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
+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,
})
);
+14 -1
View File
@@ -88,6 +88,20 @@ export type Selector =
| UiTimeFormatSelector
| BackupLocationSelector;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type SelectorType = KeysOfUnion<Selector>;
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
? K extends keyof U
? U
: never
: never;
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
Selector,
T
>;
export interface ActionSelector {
action: {
optionsInSidebar?: boolean;
@@ -366,7 +380,6 @@ export interface MediaSelector {
media: {
accept?: string[];
image_upload?: boolean;
clearable?: boolean;
hide_content_type?: boolean;
content_id_helper?: string;
} | null;
+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
@@ -116,10 +116,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
<ha-button @click=${this._save} .disabled=${this._submitting}>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
+1 -1
View File
@@ -235,7 +235,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
<ha-alert slot="footer">
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
user_profile: html`<a href="/profile/browser#shortcuts"
>${this._i18n.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
+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> {
+81 -16
View File
@@ -16,6 +16,7 @@ import {
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
@@ -77,6 +78,10 @@ export class HAFullCalendar extends LitElement {
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
@property({ attribute: false }) public events: CalendarEvent[] = [];
@property({ attribute: false }) public calendars: CalendarData[] = [];
@@ -172,13 +177,34 @@ export class HAFullCalendar extends LitElement {
</ha-icon-button-next>
</div>
<h1>${this.calendar.view.title}</h1>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
`
: html`
<div class="controls">
@@ -208,13 +234,34 @@ export class HAFullCalendar extends LitElement {
"ui.components.calendar.today"
)}</ha-button
>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
</div>
`
}
@@ -225,8 +272,15 @@ export class HAFullCalendar extends LitElement {
<div id="calendar"></div>
${
this.addFab && this._hasMutableCalendars
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle !== "header"
? html`<ha-button
size=${this.addFabSize.charAt(0)}
class=${classMap({ below: this.addFabStyle === "below" })}
slot="fab"
@click=${this._createEvent}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>`
@@ -574,6 +628,17 @@ export class HAFullCalendar extends LitElement {
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-button.below[slot="fab"] {
position: relative;
margin-inline-start: auto;
padding-top: var(--ha-space-2);
left: 0;
right: 0;
bottom: 0;
top: 0;
--ha-button-box-shadow: none;
}
#calendar {
flex-grow: 1;
background-color: var(
@@ -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(
@@ -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(
@@ -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;
}
@@ -28,7 +28,7 @@ import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../../../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../config-sections";
@@ -14,7 +14,7 @@ import {
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../../../components/ha-config-navigation-list";
@customElement("ha-config-navigation")
class HaConfigNavigation extends LitElement {
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -12,6 +12,15 @@ function getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
const RELATIONSHIP_PRIORITY: Record<string, number> = {
Parent: 0,
Sibling: 1,
NoneOfTheAbove: 2,
Child: 3,
PreviousChild: 5,
};
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
export function createZHANetworkChartData(
devices: ZHADevice[],
hass: HomeAssistant,
@@ -159,40 +168,80 @@ export function createZHANetworkChartData(
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// For every device whose routing table was empty (so it got no link
// above), independently compute its preferred fallback neighbor before
// creating any of these links. Doing this device-by-device while
// creating links (as before) meant whichever device the backend happens
// to list first "claims" the connection, and a sibling router listed
// later never gets to contribute its own — often better — choice,
// splitting the graph into islands depending on backend device order.
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
devices.forEach((device) => {
const hasLink = links.some(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (hasLink) {
return;
}
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
// Prefer a neighbor the device's own Zigbee stack reports as its
// parent: picking by raw LQI alone regularly favors a nearby child end
// device (e.g. a plug sitting right next to its router) over the
// router's actual uplink, which severs it from the rest of the mesh in
// the visualization even though the real network is connected.
const closestNeighbor = neighbors.sort((a, b) => {
const aPriority =
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const bPriority =
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const priorityDiff = aPriority - bPriority;
return priorityDiff !== 0
? priorityDiff
: parseInt(b.lqi) - parseInt(a.lqi);
})[0];
if (closestNeighbor) {
fallbackTargets.set(device.ieee, closestNeighbor);
}
});
const addedFallbackPairs = new Set<string>();
fallbackTargets.forEach((target, ieee) => {
const pairKey = [ieee, target.ieee].sort().join("|");
if (addedFallbackPairs.has(pairKey)) {
return;
}
addedFallbackPairs.add(pairKey);
links.push({
source: ieee,
target: target.ieee,
value: parseInt(target.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
});
// Now set ignoreForceLayout to false for the best connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
@@ -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);
}
`,
];
}
+17 -13
View File
@@ -1126,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() {
+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,
@@ -112,7 +112,11 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
const oldStates = changedProps.get("hass").states;
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
const oldStates = oldHass.states;
if (
this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
@@ -120,6 +124,20 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
if (this._data && periodIncludesNow(this._data)) {
const batteries = energySourcesByType(this._data.prefs).battery;
if (
@@ -162,6 +180,21 @@ class HuiEnergyDistrubutionCard
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const gasUnit = this._data.gasUnit;
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === gasUnit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const hasReturnToGrid =
types.grid?.some((source) => source.stat_energy_to) ?? false;
@@ -437,7 +470,9 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
gasUsage,
this._data.gasUnit
this._data.gasUnit,
undefined,
gasDisplayPrecision
)}
</div>
<svg width="80" height="30">
@@ -11,6 +11,7 @@ import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
energySourcesByType,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -66,6 +67,8 @@ export class HuiEnergyGasGraphCard
@state() private _total?: number;
private _energyData?: EnergyData;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -88,11 +91,68 @@ export class HuiEnergyGasGraphCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
if (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._energyData &&
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
private get _displayPrecision(): number | undefined {
if (!this._energyData) {
return undefined;
}
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
.gas?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === this._unit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
return gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
}
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
const displayPrecision = this._displayPrecision;
return displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
}
protected render() {
@@ -111,7 +171,11 @@ export class HuiEnergyGasGraphCard
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${formatNumber(this._total, this.hass.locale)}
${formatNumber(
this._total,
this.hass.locale,
this._gasFormatOptions
)}
${this._unit}
</hui-energy-graph-chip>`
: nothing
@@ -135,7 +199,7 @@ export class HuiEnergyGasGraphCard
this._unit,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._displayPrecision ?? this._yAxisFractionDigits
)}
chart-type="bar"
></ha-chart-base>
@@ -162,7 +226,10 @@ export class HuiEnergyGasGraphCard
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
{
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
unit: this._unit,
}
);
private _createOptions = memoizeOne(
@@ -191,6 +258,8 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._energyData = energyData;
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
@@ -87,11 +87,35 @@ export class HuiEnergySourcesTableCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
if (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
protected _renderRow(
@@ -108,6 +132,20 @@ export class HuiEnergySourcesTableCard
compare: boolean,
name?: string
) {
const displayPrecision =
type === "gas" &&
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
? this.hass.entities[statId]?.display_precision
: undefined;
const formatOptions =
displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
return html`<tr
class="mdc-data-table__row ${classMap({
clickable: !isExternalStatistic(statId),
@@ -151,7 +189,8 @@ export class HuiEnergySourcesTableCard
${
compare
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
${energyUnit}
</td>
${
showCosts
@@ -172,7 +211,7 @@ export class HuiEnergySourcesTableCard
: ""
}
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(energy, this.hass.locale)} ${energyUnit}
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
</td>
${
showCosts
@@ -203,7 +242,8 @@ export class HuiEnergySourcesTableCard
showCosts: boolean,
compare: boolean,
bulletColor?: { border: string; background: string },
isFinalTotal?: boolean
isFinalTotal?: boolean,
formatOptions?: Intl.NumberFormatOptions
) {
return html` <tr
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
@@ -228,7 +268,11 @@ export class HuiEnergySourcesTableCard
${
compareEnergy === null
? ""
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
: `${formatNumber(
compareEnergy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
}
</td>
${
@@ -253,7 +297,11 @@ export class HuiEnergySourcesTableCard
${
energy === null
? ""
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
: `${formatNumber(
energy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
}
</td>
${
@@ -358,6 +406,30 @@ export class HuiEnergySourcesTableCard
water: this._data.waterUnit,
};
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === units.gas
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const gasFormatOptions =
gasDisplayPrecision !== undefined
? {
minimumFractionDigits: gasDisplayPrecision,
maximumFractionDigits: gasDisplayPrecision,
}
: undefined;
const compare = this._data.statsCompare !== undefined;
const _extractStatData = (
@@ -481,7 +553,9 @@ export class HuiEnergySourcesTableCard
0
),
}
: undefined
: undefined,
false,
type === "gas" ? gasFormatOptions : undefined
)
: ""
}`;
+12 -13
View File
@@ -222,6 +222,9 @@ export class HuiCalendarCard
"has-title": !!this._config.title,
loading: loading,
})}
?add-fab=${this._config.show_add_event}
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
.narrow=${this._narrow}
.events=${this._events}
.calendars=${this._calendars}
@@ -370,11 +373,7 @@ export class HuiCalendarCard
}
private _measureCard() {
const card = this.shadowRoot!.querySelector("ha-card");
if (!card) {
return;
}
this._narrow = card.offsetWidth < 870;
this._narrow = this.offsetWidth < 870;
}
private async _attachObserver(): Promise<void> {
@@ -383,12 +382,7 @@ export class HuiCalendarCard
debounce(() => this._measureCard(), 250, false)
);
}
const card = this.shadowRoot!.querySelector("ha-card");
// If we show an error or warning there is no ha-card
if (!card) {
return;
}
this._resizeObserver.observe(card);
this._resizeObserver.observe(this);
}
static styles = css`
@@ -414,9 +408,9 @@ export class HuiCalendarCard
ha-full-calendar {
--calendar-height: 400px;
display: block;
display: flex;
width: 100%;
height: var(--calendar-height);
height: 100%;
min-height: var(--calendar-height);
}
@@ -431,9 +425,14 @@ export class HuiCalendarCard
ha-full-calendar.is-grid.has-title,
ha-full-calendar.is-panel.has-title {
--header-height: calc(
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
var(--ha-line-height-condensed) + 16px
);
--calendar-height: calc(
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
);
height: calc(100% - var(--header-height));
}
.loading {
+3
View File
@@ -52,6 +52,9 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
initial_view?: FullCalendarView;
title?: string;
theme?: string;
show_add_event?: boolean;
add_event_style?: "below" | "on_top" | "header";
add_event_size?: "small" | "medium" | "large";
}
export interface ConditionalCardConfig extends LovelaceCardConfig {
@@ -104,7 +104,6 @@ export class HuiImageElementEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -10,12 +10,16 @@ import {
optional,
string,
union,
literal,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-entities-picker";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { CalendarCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
@@ -27,6 +31,13 @@ const cardConfigStruct = assign(
title: optional(union([string(), boolean()])),
initial_view: optional(string()),
theme: optional(string()),
show_add_event: optional(boolean()),
add_event_style: optional(
union([literal("below"), literal("on_top"), literal("header")])
),
add_event_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
entities: array(string()),
})
);
@@ -72,7 +83,83 @@ export class HuiCalendarCardEditor
],
},
{ name: "theme", required: false, selector: { theme: {} } },
] as const
{ name: "show_add_event", required: false, selector: { boolean: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "add_event_style",
default: "below",
visible: { field: "show_add_event", operator: "eq", value: true },
required: false,
selector: {
select: {
options: [
{
value: "below",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
),
},
{
value: "on_top",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
),
},
{
value: "header",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
),
},
],
mode: "dropdown",
},
},
},
{
name: "add_event_size",
default: "small",
visible: [
{ field: "show_add_event", operator: "eq", value: true },
{
field: "add_event_style",
operator: "not_eq",
value: "header",
},
],
required: false,
selector: {
select: {
options: [
{
value: "small",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
),
},
{
value: "medium",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
),
},
{
value: "large",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
),
},
],
mode: "dropdown",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
@@ -44,7 +44,6 @@ export class HuiPictureCardEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -124,7 +124,6 @@ export class HuiPictureElementsCardEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -138,7 +137,6 @@ export class HuiPictureElementsCardEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -82,7 +82,6 @@ export class HuiPictureEntityCardEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -78,7 +78,6 @@ export class HuiPictureGlanceCardEditor
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
@@ -43,7 +43,6 @@ export class HuiViewBackgroundEditor extends LitElement {
selector: {
media: {
accept: ["image/*"] as string[],
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: this.hass.localize(
@@ -165,7 +164,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
@@ -0,0 +1,32 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,6 +34,7 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -271,15 +272,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
};
}
@@ -4,8 +4,9 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -25,13 +26,6 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -63,7 +57,9 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
return section;
}
@@ -110,7 +106,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(toTileCard));
section.cards!.push(...entities.map(computeFavoriteCardConfig));
return section;
}
}
@@ -15,10 +15,8 @@ import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
type EntityRegistryDisplayEntry,
findBatteryChargingEntity,
} from "../../../data/entity/entity_registry";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
@@ -37,44 +35,42 @@ export const maintenanceEntityFilters: EntityFilter[] = [
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntities = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): EntityRegistryDisplayEntry[] => {
const entries = Object.values(entities);
return entries.filter((entity) => entity.device_id === deviceId);
}
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
getDeviceEntityDisplayLookup(Object.values(entities))
);
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] =>
entityIds.filter((entityId) => {
): string[] => {
return entityIds.filter((entityId) => {
const state = hass.states[entityId]?.state ?? "";
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
}
const deviceId = hass.entities[entityId]?.device_id;
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
entities
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity?.entity_id]
: undefined;
if (batteryCharging && batteryCharging.state === "on") {
const stateValue = parseFloat(state);
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
return false;
}
const stateValue = parseFloat(state);
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
const deviceId = hass.entities[entityId]?.device_id;
if (!deviceId) {
return true;
}
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
_deviceEntityLookup(hass.entities)[deviceId] ?? []
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity.entity_id]
: undefined;
return batteryCharging?.state !== "on";
});
};
export const filterUnavailableBatteryEntities = (
hass: HomeAssistant,
+9
View File
@@ -322,6 +322,15 @@ export const getMyRedirects = (): Redirects => ({
profile_security: {
redirect: "/profile/security",
},
profile_preferences: {
redirect: "/profile/preferences",
},
profile_localization: {
redirect: "/profile/localization",
},
profile_browser: {
redirect: "/profile/browser",
},
profile: {
redirect: "/profile",
},
+1 -1
View File
@@ -77,7 +77,7 @@ class HaPanelNotFound extends LitElement {
),
tap_action: {
action: "navigate",
navigation_path: "/profile/general",
navigation_path: "/profile/dashboard",
},
},
],
+17 -21
View File
@@ -1,38 +1,34 @@
import { customElement, property } from "lit/decorators";
import { mdiAccount, mdiLock } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
export const profileSections: PageNavigation[] = [
{
path: "/profile/general",
translationKey: "ui.panel.profile.tabs.general",
iconPath: mdiAccount,
},
{
path: "/profile/security",
translationKey: "ui.panel.profile.tabs.security",
iconPath: mdiLock,
},
];
@customElement("ha-panel-profile")
class HaPanelProfile extends SubscribeMixin(HassRouterPage) {
class HaPanelProfile extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected routerOptions: RouterOptions = {
defaultPage: "general",
defaultPage: "dashboard",
routes: {
general: {
tag: "ha-profile-section-general",
load: () => import("./ha-profile-section-general"),
dashboard: {
tag: "ha-profile-dashboard",
load: () => import("./ha-profile-dashboard"),
},
preferences: {
tag: "ha-profile-section-preferences",
load: () => import("./ha-profile-section-preferences"),
},
localization: {
tag: "ha-profile-section-localization",
load: () => import("./ha-profile-section-localization"),
},
browser: {
tag: "ha-profile-section-browser",
load: () => import("./ha-profile-section-browser"),
},
security: {
tag: "ha-profile-section-security",
+1
View File
@@ -81,6 +81,7 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
darkMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.dark"
),
colors: this.hass.localize("ui.panel.profile.themes.colors"),
primaryColor: this.hass.localize(
"ui.panel.profile.themes.primary_color"
),
+220
View File
@@ -0,0 +1,220 @@
import {
mdiAccount,
mdiCellphone,
mdiEarth,
mdiLock,
mdiMonitor,
mdiTune,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-config-navigation-list";
import "../../components/ha-svg-icon";
import "./ha-pick-theme-row";
import { isExternal } from "../../data/external";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-subpage";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@customElement("ha-profile-dashboard")
class HaProfileDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
const pages: PageNavigation[] = [
{
path: "/profile/preferences",
name: this.hass.localize("ui.panel.profile.user_preferences_header"),
description: this.hass.localize(
"ui.panel.profile.user_preferences_description"
),
iconPath: mdiTune,
},
{
path: "/profile/localization",
name: this.hass.localize("ui.panel.profile.localization_header"),
description: this.hass.localize(
"ui.panel.profile.localization_description"
),
iconPath: mdiEarth,
},
{
path: "/profile/browser",
name: this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
),
description: this.hass.localize("ui.panel.profile.browser_description"),
iconPath: isExternal ? mdiCellphone : mdiMonitor,
},
{
path: "/profile/security",
name: this.hass.localize("ui.panel.profile.tabs.security"),
description: this.hass.localize(
"ui.panel.profile.security_description"
),
iconPath: mdiLock,
},
];
return html`
<hass-subpage
main-page
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("panel.profile")}
>
<div class="container">
<ha-card>
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon .path=${mdiAccount}></ha-svg-icon>
</div>
<div class="details">
${this.hass.user!.name}
${
this.hass.user!.is_owner
? html`<br /><small
>${this.hass.localize(
"ui.panel.profile.is_owner"
)}</small
>`
: ""
}
</div>
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize("ui.panel.profile.themes.header")}
>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
</ha-card>
<ha-card outlined>
<ha-config-navigation-list
.hass=${this.hass}
.pages=${pages}
has-secondary
.label=${this.hass.localize("panel.profile")}
></ha-config-navigation-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-card[outlined] {
overflow: hidden;
}
.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--primary-color);
opacity: 0.2;
}
.icon ha-svg-icon {
color: var(--primary-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.details small {
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
ha-button {
margin-inline-start: auto;
flex-shrink: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-dashboard": HaProfileDashboard;
}
}
@@ -0,0 +1,130 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import "../../components/ha-card";
import "../../components/ha-md-list";
import { isExternal } from "../../data/external";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-force-narrow-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-browser")
class HaProfileSectionBrowser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-md-list>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: ""
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row
.hass=${this.hass}
></ha-set-vibrate-row>
`
: ""
}
${
!isExternal &&
isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: ""
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-browser": HaProfileSectionBrowser;
}
}
@@ -1,305 +0,0 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/item/ha-row-item";
import { isExternal } from "../../data/external";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-tabs-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-enable-shortcuts-row";
import "./ha-entity-id-picker-row";
import "./ha-force-narrow-row";
import { profileSections } from "./ha-panel-profile";
import "./ha-pick-dashboard-row";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-theme-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _coreUserData?: CoreFrontendUserData | null;
@property({ attribute: false }) public route!: Route;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
this._scrollToHash();
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
private async _scrollToHash() {
await nextRender();
const hash = window.location.hash.substring(1);
if (hash) {
const element = this.shadowRoot!.getElementById(hash);
element?.scrollIntoView();
this._clearHash();
}
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
main-page
.hass=${this.hass}
.tabs=${profileSections}
.route=${this.route}
>
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
<ha-card .header=${this.hass.user!.name}>
<div class="card-content">
${this.hass.localize("ui.panel.profile.current_user", {
fullName: this.hass.user!.name,
})}
${
this.hass.user!.is_owner
? this.hass.localize("ui.panel.profile.is_owner")
: ""
}
</div>
<div class="card-actions">
<ha-button
variant="danger"
appearance="plain"
@click=${this._handleLogOut}
>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-row-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: nothing
}
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
<ha-card
.header=${this.hass.localize(
isExternal
? "ui.panel.profile.mobile_app_settings"
: "ui.panel.profile.browser_settings"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
${
this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row
.hass=${this.hass}
></ha-force-narrow-row>
`
: nothing
}
${
"vibrate" in navigator
? html`
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
`
: nothing
}
${
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
? html`
<ha-push-notifications-row
.hass=${this.hass}
></ha-push-notifications-row>
`
: nothing
}
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
${
!isMobileClient
? html`
<ha-enable-shortcuts-row
id="shortcuts"
.hass=${this.hass}
></ha-enable-shortcuts-row>
`
: nothing
}
</ha-card>
</div>
</hass-tabs-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
private _handleLogOut() {
showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.profile.logout_title"),
text: this.hass.localize("ui.panel.profile.logout_text"),
confirmText: this.hass.localize("ui.panel.profile.logout"),
confirm: () => fireEvent(this, "hass-logout"),
destructive: true,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
display: block;
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.content > * {
display: block;
margin: 24px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-general": HaProfileSectionGeneral;
}
}
@@ -0,0 +1,98 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-time-format-row";
import "./ha-pick-time-zone-row";
@customElement("ha-profile-section-localization")
class HaProfileSectionLocalization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.localization_header")}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.localization_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.localization_detail")}
</div>
<ha-pick-language-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-language-row>
<ha-pick-time-zone-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-zone-row>
<ha-pick-number-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-date-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-date-format-row>
<ha-pick-first-weekday-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
</ha-card>
</div>
</hass-subpage>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-localization": HaProfileSectionLocalization;
}
}
@@ -0,0 +1,162 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
import "../../layouts/hass-subpage";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import "./ha-entity-id-picker-row";
import "./ha-pick-dashboard-row";
@customElement("ha-profile-section-preferences")
class HaProfileSectionPreferences extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _coreUserData?: CoreFrontendUserData | null;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
}
public connectedCallback() {
super.connectedCallback();
if (this.hass) {
this._getCoreData();
}
}
public firstUpdated() {
if (!this._unsubCoreData) {
this._getCoreData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData = undefined;
}
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/profile"
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="container">
<ha-card
.header=${this.hass.localize(
"ui.panel.profile.user_preferences_header"
)}
>
<div class="card-content">
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
</div>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-md-list-item>
${
this.hass.user!.is_admin
? html`
<ha-entity-id-picker-row
.hass=${this.hass}
.coreUserData=${this._coreUserData}
></ha-entity-id-picker-row>
`
: ""
}
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _customizeSidebar() {
showEditSidebarDialog(this);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-profile-section-preferences": HaProfileSectionPreferences;
}
}
@@ -1,8 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import "../../layouts/hass-subpage";
import type { RefreshToken } from "../../data/refresh_token";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
@@ -32,14 +31,12 @@ class HaProfileSectionSecurity extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
main-page
<hass-subpage
.hass=${this.hass}
.tabs=${profileSections}
.route=${this.route}
back-path="/profile"
.header=${this.hass.localize("ui.panel.profile.tabs.security")}
>
<div slot="title">${this.hass.localize("panel.profile")}</div>
<div class="content">
<div class="container">
${
this.hass.user!.credentials.some(
(cred) => cred.auth_provider_type === "homeassistant"
@@ -70,7 +67,7 @@ class HaProfileSectionSecurity extends LitElement {
@hass-refresh-tokens=${this._refreshRefreshTokens}
></ha-long-lived-access-tokens-card>
</div>
</hass-tabs-subpage>
</hass-subpage>
`;
}
@@ -93,21 +90,15 @@ class HaProfileSectionSecurity extends LitElement {
-moz-user-select: initial;
}
.content {
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
.container > * {
display: block;
margin: 0 auto var(--ha-space-4);
max-width: 600px;
margin: 0 auto;
padding-bottom: var(--safe-area-inset-bottom);
}
.content > * {
display: block;
margin: 24px 0;
}
.promo-advanced {
text-align: center;
color: var(--secondary-text-color);
}
`,
];
@@ -18,11 +18,9 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -186,15 +184,7 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
+28 -7
View File
@@ -8403,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",
@@ -9921,6 +9924,21 @@
"dayGridWeek": "Week",
"dayGridDay": "Day",
"listWeek": "List (7 days)"
},
"show_add_event": "Show add event button",
"add_event_style": "Add event button style",
"add_event_size": "Add event button size",
"add_event": {
"style": {
"header": "Header",
"below": "Below",
"on_top": "On top"
},
"size": {
"s": "Small",
"m": "Medium",
"l": "Large"
}
}
},
"conditional": {
@@ -11177,17 +11195,19 @@
},
"profile": {
"tabs": {
"general": "General",
"security": "Security"
},
"current_user": "You are currently logged in as {fullName}.",
"is_owner": "You are an owner.",
"user_preferences_header": "User preferences",
"user_preferences_header": "Appearance",
"user_preferences_description": "Default dashboard and sidebar",
"user_preferences_detail": "The following settings are tied to your account and will persist across all sessions and devices.",
"localization_header": "Localization",
"localization_description": "Language, time zone, date, time, and number formats",
"localization_detail": "These settings are tied to your account and control how dates, times, and numbers are displayed.",
"mobile_app_settings": "Mobile app settings",
"browser_settings": "Browser settings",
"mobile_app_settings": "This mobile app",
"browser_settings": "This browser",
"browser_description": "Notifications, display, and shortcuts",
"security_description": "Password, multi-factor authentication, and access tokens",
"client_settings_detail": "The following settings are local to this client only, and may reset to defaults on logout or when local data is cleared.",
"logout": "Log out",
"logout_title": "Log out?",
@@ -11302,6 +11322,7 @@
"migrate_success": "Theme settings migrated.",
"migrate_failed": "Failed to migrate theme settings.",
"theme_mode": "Theme mode",
"colors": "Custom colors",
"dark_mode": {
"auto": "Auto",
"light": "Light",
@@ -11309,7 +11330,7 @@
},
"primary_color": "Primary color",
"accent_color": "Accent color",
"reset": "Reset",
"reset": "Reset colors",
"use_default": "Use default theme"
},
"dashboard": {
+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";
}
+106
View File
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
// that changed it would silently ship local-only names again.
const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
};
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
if (!Array.isArray(expression)) {
return expression;
}
const [op, ...args] = expression;
switch (op) {
case "get":
return properties[args[0]];
case "has":
return args[0] in properties;
case "!":
return !evaluate(args[0], properties);
case "<":
return evaluate(args[0], properties) < evaluate(args[1], properties);
case "concat":
return args.map((arg) => evaluate(arg, properties)).join("");
case "format":
return args
.filter((_, i) => i % 2 === 0)
.map((arg) => evaluate(arg, properties))
.join("");
case "case":
for (let i = 0; i < args.length - 1; i += 2) {
if (evaluate(args[i], properties)) {
return evaluate(args[i + 1], properties);
}
}
return evaluate(args[args.length - 1], properties);
default:
throw new Error(`Unexpected operator ${op}`);
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
"1er arrondissement"
);
});
it("adds the English name under a non-Latin one", () => {
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
"Москва\nMoscow"
);
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
"بيروت\nBeirut"
);
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
"東京\nTokyo"
);
});
it("sets the English line smaller", () => {
expect(city.at(-1)).toEqual(
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
);
});
it("falls back to the local name without an English one", () => {
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
});
it("keeps street labels on one line", () => {
expect(
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
).toBe("شارع الحمرا (Hamra Street)");
});
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
+16
View File
@@ -18,6 +18,10 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
const STYLE = {
version: 8,
sources: {},
@@ -109,6 +113,18 @@ describe("createBaseLayer", () => {
browser.retina = false;
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
await createBaseLayer(leaflet, map, false);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
+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();
});
});
@@ -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();
});
});
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
import type { Selector } from "../../../src/data/selector";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const requiredField = (selector: Selector): HaFormSchema => ({
name: "value",
required: true,
selector,
});
const optionalField = (selector: Selector): HaFormSchema => ({
name: "value",
required: false,
selector,
});
describe("computeInitialHaFormData", () => {
it("initializes required text selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple text selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
text: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("initializes required object selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple object selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
object: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("leaves a required single device class selector unset", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
},
}),
])
).toEqual({});
});
it("initializes a required multiple device class selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("does not initialize optional text selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ text: {} }),
{
...optionalField({
text: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("does not initialize optional object selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ object: {} }),
{
...optionalField({
object: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("initializes a required constant selector without losing falsy values", () => {
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: false,
},
}),
])
).toEqual({
value: false,
});
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: 0,
},
}),
])
).toEqual({
value: 0,
});
});
it("initializes a required choose selector from a constant first choice", () => {
const schema = [
{
name: "match",
required: true,
selector: {
choose: {
choices: {
Disabled: {
selector: {
constant: {
value: "",
},
},
},
Enabled: {
selector: {
number: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
match: {
active_choice: "Disabled",
Disabled: "",
},
});
});
it("initializes a required choose selector from its child selector", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
text: {
multiple: true,
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
mode: {
active_choice: "First",
First: [],
},
});
});
it("throws for an unsupported required selector", () => {
expect(() =>
computeInitialHaFormData([
requiredField({
ui_action: {
default_action: "none",
},
}),
])
).toThrow("Selector ui_action not supported in initial form data");
});
it("omits a first choose child without an initial value", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
ui_action: {
default_action: "none",
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toStrictEqual({
mode: {
active_choice: "First",
},
});
});
});
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ObjectSelector } from "../../../src/data/selector";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
@@ -51,12 +55,23 @@ const selectorConfig = {
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
const mountSelector = async (value: Record<string, string>[]) => {
interface ItemActionEvent {
stopPropagation: () => void;
currentTarget: {
item?: FormDialogData;
index?: number;
};
}
const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = selectorConfig;
selector.selector = config;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
@@ -66,8 +81,8 @@ const mountSelector = async (value: Record<string, string>[]) => {
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: Record<string, string> | null,
item?: Record<string, string>,
result: FormDialogData | null,
item?: FormDialogData,
index?: number
) => {
let params: FormDialogParams | undefined;
@@ -87,15 +102,14 @@ const resolveFormDialog = async (
);
});
const event = {
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
)(event);
await dialogShown;
await operation;
await Promise.all([dialogShown, operation]);
return params!;
};
@@ -105,6 +119,105 @@ afterEach(() => {
});
describe("ha-selector-object form dialog flow", () => {
it("initializes Add dialog data from the object field schema", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
states: {
required: true,
selector: {
text: {
multiple: true,
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
states: ["on"],
});
expect(params.data).toEqual({
name: "",
states: [],
});
});
it("leaves unsupported required fields unset in Add dialog data", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
tap_action: {
required: true,
selector: {
ui_action: {
default_action: "none",
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
tap_action: {
action: "none",
},
});
expect(params.data).toEqual({
name: "",
});
});
it("restores the initialized empty value after an add-delete round trip", async () => {
const selector = await mountSelector("", {
object: {
fields: {
name: {
selector: { text: {} },
},
},
},
});
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "A" });
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
selector.value = valueChanged.mock.calls[0][0].detail.value;
await selector.updateComplete;
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: {
index: 0,
},
};
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
event
);
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
});
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
@@ -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"]);
});
});
+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);
});
});
+2 -2
View File
@@ -101,7 +101,7 @@ test.describe("Home Assistant Demo", () => {
);
}, DEMO_THEME_STORAGE_KEY);
await loadDemo(page, "/#/profile/general");
await loadDemo(page, "/#/profile/dashboard");
const themeRow = page.locator("ha-pick-theme-row");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
@@ -121,7 +121,7 @@ test.describe("Home Assistant Demo", () => {
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expectDemoDarkMode(page, true);
await loadDemo(page, "/#/profile/general");
await loadDemo(page, "/#/profile/dashboard");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(
themeRow.locator('ha-radio-option[value="dark"]')
+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,295 @@
import { describe, expect, it } from "vitest";
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
import type { ZHADevice } from "../../../../../../src/data/zha";
import type { HomeAssistant } from "../../../../../../src/types";
const hass = {
devices: {},
areas: {},
localize: () => "",
} as unknown as HomeAssistant;
const device = (partial: Partial<ZHADevice>): ZHADevice =>
({
available: true,
name: partial.ieee!,
lqi: 0,
rssi: "",
last_seen: "",
manufacturer: "",
model: "",
quirk_applied: false,
quirk_class: "",
entities: [],
manufacturer_code: 0,
device_reg_id: partial.ieee!,
active_coordinator: false,
signature: {},
routes: [],
neighbors: [],
...partial,
}) as ZHADevice;
describe("createZHANetworkChartData", () => {
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
// never populate Mgmt_Rtg routes, so the chart has to fall back to
// picking a device's strongest RF neighbor. A router's own child end
// device (e.g. a plug sitting right next to it) commonly reports a
// stronger LQI than the router's real uplink, which must not be
// allowed to hide the backbone connection.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
// Real backbone link: weaker signal, correctly flagged as a sibling router
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
// Nearby child end device with a much stronger signal than the real uplink
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
// Every device should be reachable from the coordinator. Before the
// fix, downstream-router picked its child as its only link, leaving
// it (and its children) as a disconnected island in the chart even
// though the real network is fully connected.
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
const router = device({
ieee: "router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "n-child",
nwk: "0x0001",
lqi: "100",
depth: "1",
relationship: "Child",
},
{
ieee: "n-none-of-the-above",
nwk: "0x0002",
lqi: "100",
depth: "1",
relationship: "NoneOfTheAbove",
},
],
});
const unknownRelationship = device({
ieee: "router-2",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "n-unrecognized",
nwk: "0x0003",
lqi: "100",
depth: "1",
relationship: "SomeFutureValue",
},
{
ieee: "n-previous-child",
nwk: "0x0004",
lqi: "100",
depth: "1",
relationship: "PreviousChild",
},
],
});
const { links } = createZHANetworkChartData(
[router, unknownRelationship],
hass,
document.createElement("div")
);
const otherEndOf = (ieee: string) => {
const link = links.find((l) => l.source === ieee || l.target === ieee);
return link?.source === ieee ? link.target : link?.source;
};
expect(otherEndOf("router")).toBe("n-none-of-the-above");
expect(otherEndOf("router-2")).toBe("n-unrecognized");
});
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
// Regression test for a subtler variant of the same bug: the fallback
// link is only computed for a device if it doesn't already have a
// link. If the backend lists a child end device before its parent
// router, the child claims the link first, and the router - now
// appearing to "already have a link" - never gets to evaluate its own
// (better) neighbor choice, splitting the graph exactly as before but
// triggered by device order instead of by LQI.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
// Same topology as the first test, but the child is listed before its
// parent router this time.
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
});
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
it("filters numeric battery entities by the low battery threshold", () => {
const hass = createMockHass({
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
device_class: "battery",
}),
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
device_class: "battery",
}),
});
expect(
filterLowBatteryEntities(hass, [
"sensor.low_battery",
"sensor.ok_battery",
])
).toEqual(["sensor.low_battery"]);
});
it("excludes a low battery when its device is charging", () => {
const entities = {
"sensor.device_1_battery": mockEntity({
entity_id: "sensor.device_1_battery",
device_id: "device_1",
}),
"binary_sensor.device_1_battery_charging": mockEntity({
entity_id: "binary_sensor.device_1_battery_charging",
device_id: "device_1",
}),
"sensor.device_2_battery": mockEntity({
entity_id: "sensor.device_2_battery",
device_id: "device_2",
}),
"binary_sensor.device_2_battery_charging": mockEntity({
entity_id: "binary_sensor.device_2_battery_charging",
device_id: "device_2",
}),
};
const hass = createMockHass(
{
"sensor.device_1_battery": createMockEntityState(
"sensor.device_1_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_1_battery_charging": createMockEntityState(
"binary_sensor.device_1_battery_charging",
"on",
{ device_class: "battery_charging" }
),
"sensor.device_2_battery": createMockEntityState(
"sensor.device_2_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_2_battery_charging": createMockEntityState(
"binary_sensor.device_2_battery_charging",
"off",
{ device_class: "battery_charging" }
),
},
{ entities }
);
expect(
filterLowBatteryEntities(hass, [
"sensor.device_1_battery",
"sensor.device_2_battery",
])
).toEqual(["sensor.device_2_battery"]);
});
it("keeps binary battery sensor behavior unchanged", () => {
const hass = createMockHass({
"binary_sensor.low_battery": createMockEntityState(
"binary_sensor.low_battery",
"on",
{ device_class: "battery" }
),
"binary_sensor.ok_battery": createMockEntityState(
"binary_sensor.ok_battery",
"off",
{ device_class: "battery" }
),
});
expect(
filterLowBatteryEntities(hass, [
"binary_sensor.low_battery",
"binary_sensor.ok_battery",
])
).toEqual(["binary_sensor.low_battery"]);
});
it("updates the device lookup when the entity registry changes", () => {
const states = {
"sensor.battery": createMockEntityState("sensor.battery", "10", {
device_class: "battery",
}),
"binary_sensor.battery_charging": createMockEntityState(
"binary_sensor.battery_charging",
"on",
{ device_class: "battery_charging" }
),
};
const firstHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_1",
}),
},
});
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
const secondHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_2",
}),
},
});
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
"sensor.battery",
]);
});
});
+784 -319
View File
File diff suppressed because it is too large Load Diff