Compare commits

..
Author SHA1 Message Date
Bruno Pantaleão a9be1c91d3 Let companion apps draw the top bar back button
Apps that report hasNativeBackButton draw the back button themselves. The frontend hides its own arrow and reports whether the current top bar offers a back action, then performs the navigation when the app says the button was tapped.
2026-09-22 18:58:47 +01:00
Aidan TimsonandNorbert Rittel 117392d71e Add a bug report link to log details (#54305)
* Share GitHub issue URLs and type integration queries

* Share the documentation base URL

* Add a bug report link to log details

* Add horizontal divider

* Name the destination in the log report link text

* Move log reporting into the alert below the header

* Clarify log reporting guidance and alert spacing

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <[email protected]>

* Prefill installation type in log reports

* Add serial comma to log reporting guidance

* Fix log report loading and large log handling

* Clarify custom Frontend modules in log reporting guidance

* Use version-aware documentation links in log reports

* Use contexts and limit health requests in log details

---------

Co-authored-by: Norbert Rittel <[email protected]>
2026-09-22 12:07:59 +02:00
Paulus SchoutsenandClaude 666f88c4da Correct automation, script and trace API types in the frontend (#54291)
Compare the automation, script, trace and blueprint data modules with
the schemas and handlers that produce them, and fix the mismatches.

Add trigger options the backend accepts and the frontend did not
declare, and widen the offsets and time values the schemas allow.
Device actions and their capabilities are often absent, so mark them
optional. Correct several declared results, including saving and
deleting a blueprint, and drop a trace field the backend never sets.


Claude-Session: https://claude.ai/code/session_01YDqKNfAAvMV7zJJNodsHfK

Co-authored-by: Claude <[email protected]>
2026-09-21 18:34:07 +02:00
b12f78c652 Climate dashboard: add my link (#54299)
Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-09-21 17:30:33 +01:00
Jan-Philipp Benecke 8e10d53012 Render real trigger/condition/action icons and unfinished branch state in trace graph (#54275)
* Render real trigger/condition/action icons and unfinished branch state in trace graph

* Process review: repeat body tracking, service and action icons

* Infer empty repeat body entry from the repeat config

* Track repeat bodies only by their traced steps
2026-09-21 18:30:23 +02:00
Aidan Timson 1813f386de Add field IDs to the bug report form (#54303)
* Add stable field IDs for frontend issue prefill

* Remove issue template prefill comment

* Add IDs to remaining issue form fields

* Link the issue reporting guide in the bug report form
2026-09-21 18:26:46 +02:00
c0ffeeca7 80202cd645 Connectivity panel: add my link (#54296)
Add my link for Connectivity panel
2026-09-21 18:16:49 +02:00
c0ffeeca7andAidan Timson c786f7d2e6 Lights dashboard: add my link (#54297)
Co-authored-by: Aidan Timson <[email protected]>
2026-09-21 18:16:24 +02:00
Paulus SchoutsenandClaude 1ac4382dc5 Correct Supervisor API types in the frontend (#54284)
Compare the hassio and supervisor data modules with the Supervisor
handlers that build the responses, and fix the mismatches.

Add fields the API returns that were missing, including system_managed,
boot_config, dns, host_uts, ingress_port, udev, usb and video. Correct
two names that never matched the wire: services_role is services, and
auto_uart is uart. Drop installed, which belongs to the store model
rather than the app info payload, and drop repositories from the app
list, which the list handler does not return.

Mark the many fields the Supervisor can return as null, and replace the
placeholder issue and suggestion string arrays in the resolution types
with the objects the API sends.


Claude-Session: https://claude.ai/code/session_01YDqKNfAAvMV7zJJNodsHfK

Co-authored-by: Claude <[email protected]>
2026-09-21 18:13:19 +02:00
Sirakorn Lamyai d0220b6a72 No username in filter condition for eligible users, to allow Command Line auth provider (#54280)
* fix: no username in filter condition for eligible users

* fix: hide user.username in brackets if username is null

* fix: hide username/password row only when linked user has homeassistant credential

* fix: validate ability to login using credentials length
2026-09-21 12:18:08 +02:00
c0ffeeca7 bf21bfa777 Modbus panel: add my link (#54302) 2026-09-21 11:02:07 +01:00
c0ffeeca7 1fc565b602 Security dashboard: add my link (#54298) 2026-09-21 10:33:43 +01:00
7ac932ff7e Use the tool result field in the chat log (#54294)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-09-21 09:57:30 +01:00
Petar Petrov 996ccd50d6 Filter config entry picker by requested subentry type (#54224) 2026-09-21 09:29:15 +01:00
Petar Petrov 24fde161c4 Fix calendar card refresh loop on browsers without Intl longOffset support (#54221) 2026-09-21 09:26:39 +01:00
Bruno Pantaleão Gonçalves d005f694e1 Let the map overview list scroll through the bottom safe area (#54282)
* Let the map sheet reach the screen edges

On a phone the map bleeds past the view's safe-area padding but the overview
sheet over it did not, so a strip of map stayed visible down the sheet's
trailing side and under it. The sheet now bleeds to the same edges.

Reaching the bottom of the screen means the list can scroll through the safe
area instead of stopping above it. The list keeps that much padding at its own
end, so the last row still clears the area once it is scrolled to.

* Drop the comments

* Drop the map card offsets, which never moved the sheet

On a phone the overview is a position: fixed dialog inside
ha-resizable-bottom-sheet, anchored to the viewport with its own bottom and
side insets, which this component already sets to zero. Offsetting the host
around it moved nothing, so the change was inert.

What is left is the part that runs inside the sheet: the list scrolling
through the panel's bottom padding instead of stopping above it.
2026-09-21 07:07:45 +02:00
karwosts 299e612646 Make helpers table robust to entity-id changes (#54261)
* Remove duplicate entityReg subscription in helpers

* initialize to undefined

* Make helpers table robust to entity-id changes
2026-09-20 11:04:50 +02:00
Jan-Philipp Benecke 5fa14d4173 Refresh trace graph primitives to match automation editor (#54253) 2026-09-20 11:04:37 +02:00
Petar Petrov a4d1cb0a57 Downsample the history timeline to the chart's pixel width (#54252)
* Extract the history timeline chart data transform

Move the body of state-history-chart-timeline's _generateData into
state-history-chart-timeline-data.ts, mirroring the line chart's
state-history-chart-line-data.ts. Environment inputs are passed in, so the
transform is a pure function that can be tested and benchmarked on its own.

No behaviour change.

* Downsample the history timeline to the chart's pixel width

The timeline emitted one rectangle per state change with no downsampling, so an
entity with tens of thousands of recorded changes produced tens of thousands of
rectangles and blocked the main thread for seconds inside ECharts' layout.

Bound the output by the chart's device-pixel width, the way ha-chart-base
already does for line series: segments at least one frame wide keep their exact
bounds, and runs of narrower ones resolve per frame to the state covering most
of that frame, merging with neighbours that resolve to the same state. Charts
whose state changes are all wider than a frame, which is the common case, keep
exactly the rectangles they had before.

The host also becomes a block box so it reports its own width; as an inline box
its clientWidth was always 0.

* Narrow the timeline data transform's hass parameter to the states it reads

* Pass entity states to the timeline data transform instead of hass
2026-09-20 10:49:25 +02:00
dependabot[bot] 43c3b51829 Bump the codeql-action group across 1 directory with 2 updates (#54274)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-20 09:04:49 +02:00
Jan-Philipp Benecke 66a454821a Show disabled automations as event chips instead of disabled bar (#54248) 2026-09-20 08:59:34 +03:00
Paulus SchoutsenandClaude Opus 5 4f713aa645 Fix card header line height when the title wraps (#54258)
* Tighten MCP card header line height

The shared ha-card header uses --ha-line-height-expanded (2), which leaves a
large gap between lines once "Model Context Protocol" wraps on narrow screens
or at a larger font scale. Use --ha-line-height-condensed for this header, the
same value the ZHA group pages already use.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cg5a2sBXVms8HCs2t96DNE

* Fix card header line height for all ha-card

The shared header used --ha-line-height-expanded (2), so at 24px every line
box was 48px tall. That leading doubled as the header's vertical spacing, so
a header that wraps got a 48px gap between its lines.

Separate the two concerns: --ha-line-height-condensed sets the leading, and
the padding carries the spacing the half-leading used to provide. A
single-line header goes from 76px to 73px, so existing cards keep their
look, while each extra line now adds 29px instead of 48px.

Drop the per-card override in mcp-pref, which the default now covers.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cg5a2sBXVms8HCs2t96DNE

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-20 08:57:03 +03:00
Rick van Hattem cedc2d5324 Add extended date range presets to Activity (#54273) 2026-09-19 18:55:08 -07:00
karwosts 15290231ea Remove duplicate entityReg subscription in helpers (#54257)
* Remove duplicate entityReg subscription in helpers

* initialize to undefined
2026-09-19 23:35:04 +02:00
Bruno Pantaleão Gonçalves 5960dfea0e Let the table rows reach the edges of the screen (#54267)
* Let the table rows reach the edges of the screen

The rows stopped at the safe area, so their dividers and the shading
behind a group header ended in a line down the middle of the screen. The
rows now span the full width while their content keeps the inset, so
nothing readable moves and nothing sits under the cutout.

* Bleed the row and group header backgrounds too

* Bleed the table itself so every row variant lines up
2026-09-19 23:10:14 +02:00
Bruno Pantaleão Gonçalves d830220eff Let the filters bar reach the edges of the screen (#54266)
* Let the filters bar reach the edges of the screen

The row of filter and sort controls stopped at the safe area, so on a
device with a horizontal inset it sat in a narrower strip than the list
below it, its bottom border stopping short of the edge. The bar now
spans the full width, its controls scroll under the inset, and a spacer
at the end keeps the last one clear of it by the inset plus the usual
16px. A scrolled flex row drops its end padding, so that space has to be
an item rather than padding.

The subpage keeps the inset as padding inside its scroller instead of an
outer margin, so the bar can bleed into it, and a narrow data table
gives the header row the full width rather than clipping it. The rows of
the table itself stay inside the safe area as before.

* Address review on the filters bar safe area
2026-09-19 22:55:22 +02:00
Bruno Pantaleão Gonçalves c6f72c9cf5 Let the map fill the screen on the map dashboard (#54263)
* Let the map fill the screen on the map dashboard

A map in a panel view stopped at the safe area, leaving a strip of empty
card beside it, and the drawer stopped there too. Map tiles carry no text
to read, so the map and the drawer now run to the edges of the screen,
while everything that is read or tapped stays inside the safe area: the
drawer's content, the card's buttons, and the map's own controls, which
already had insets for the drawer and now take the safe area as well.

The view container exposes the insets it applies as custom properties so
the card can cancel exactly those, and the bottom sheet takes its side
insets from properties that default to the safe area.

* Address review on the map full bleed
2026-09-19 22:37:43 +02:00
Bruno Pantaleão Gonçalves 60f7728d54 Let the settings cards reach the edges of the screen (#54269)
On a phone the cards on the settings page are full-width bands with a
border on top and bottom. They stopped at the safe area, so on a device
with a horizontal inset every band ended in a line down the middle of
the screen. The bands now span the full width and pad their contents by
the inset, so the rows inside them do not move.
2026-09-19 22:22:28 +02:00
Bruno Pantaleão Gonçalves 6014ee7744 Draw the header border across the safe area (#54265)
The header's background already reaches the edges of the screen, but the
row that carries the bottom border sits inside the header's safe area
padding, so on a device with a horizontal inset the border stopped short
of the edge. The row now spans that padding while its content stays
where it was.
2026-09-19 22:07:22 +02:00
Bruno Pantaleão Gonçalves f32833fcbe Keep the bottom tab bar inside the horizontal safe area (#54262)
On a device with a left or right inset the tabs ran underneath it, so the
last tab was clipped by the rounded corner. The bar keeps its full width,
so its background still reaches the edges of the screen, and only the
tabs inside it are inset.
2026-09-19 21:54:59 +02:00
Bruno Pantaleão Gonçalves a9295ac21b Centre toasts in the safe area (#54270)
Toasts were centred on the viewport, so on a device whose safe area is
not symmetric they sat off centre and ran under the cutout. They now
take the same centring offset the dialogs use, and the stack is capped
at the safe width so a wide toast cannot reach past the inset either.
2026-09-19 21:52:17 +02:00
Petar Petrov 66cc3f62c5 Refresh voice expose table when an entity alias changes (#54233) 2026-09-19 09:40:32 +02:00
Petar Petrov fb1a75edd4 Defer chart work while the chart is not visible (#54251)
* Defer chart work while the chart is not visible

ha-chart-base did its full update path no matter whether anyone could see
the chart: every data or options change re-ran the line downsampling and
chart.setOption, and the resulting animation kept repainting the canvas.
That applied to charts in a section hidden by a visibility condition, to
charts scrolled far off a long page, and to every chart in a background
tab, where live history charts kept paying it on each stream message.

Gate the work on rendered size, an IntersectionObserver with one viewport
of margin, and document.visibilityState. While invisible the chart records
which properties changed and replays them in a single setOption once it is
visible again. The initial setup is deferred the same way, so a chart that
first renders hidden is built at its real width, and a synced zoom that
arrives before that setup is replayed instead of dropped.

* Track chart visibility from the observers instead of measuring

Reading getBoundingClientRect() in willUpdate forced a synchronous layout
for every chart on every data or options change, right after the parent had
committed DOM. Keep the state the observers already report instead: whether
the resize entry has a non-zero content box, and whether the intersection
observer sees the element. Both start true so a chart on screen still
renders on its first update, and firstUpdated keeps one measurement so a
chart that first renders inside a hidden container defers its setup.

The observer margin is one screenful only on a phone; a desktop gets a fixed
band, where a whole viewport would cover most of the page.

A reset to the full range now clears a stored zoom rather than replaying it,
and the global reset no longer skips a chart that has not been built yet, so
a deferred chart cannot come back zoomed after everything else was reset.

* Queue a synced zoom while a chart rebuild is pending

* Use the observed chart width for the default height and downsampling

* Use MobileAwareMixin for the chart's mobile checks
2026-09-19 09:34:14 +02:00
Maarten Lakerveld 8f7184023a Improve zone editor (#54031)
* Show a zone's own icon and color on the zone dialog map

The location selector's icon option was never applied: the expression
parsed as (icon || radius) ? radius-icon : marker-icon, so the zone
dialog always showed the radius marker instead of the zone's icon. The
selector also gains a color option, and both zone dialogs pass the
zone's icon and its map color (the config panel hands the dialog the
zone's entity id for that), so the map in the dialog looks like the zone
does everywhere else, including the home zone and a live passive toggle.

* Show zone markers in the zone list

The zones list on the zone config page shows each zone as it looks on
the map: its color with its icon, or initials without one, instead of
a plain icon.

* Sort zones alphabetically with home first

The zone config page listed UI zones sorted and YAML zones after them in
state order; the map overview sorted all zones by name. Both now show the
home zone first and the rest alphabetically.

* Color the zone list and dialogs from the registry context

The zone list avatar and the zone dialog map take the registry entries; the
home zone dialog needs none, its color is fixed.

* Show zone initials in the editor and resolve color from registry

Fall back to the zone's initials (matching the list and map) when it has
no icon, and to a plain map-marker when there is no name yet, instead of
the map-marker-radius pin.

Resolve the zone's entity id reactively from the registry context the
dialog already consumes, so the marker color is correct even when the
registry loads after the dialog opens (e.g. direct /edit/ deep link).

* Preview the palette color a new zone will take

Creating a zone has no entity yet, so the marker fell back to the accent
color and then changed on save. Derive the prospective creation-order
color from the registry so the create preview matches the saved zone.

* Test the new zone's previewed color

Cover nextZoneColor: the next creation-order slot for an active zone (home
excluded) and the muted color for a passive one, so an off-by-one in the
slot count can't silently make the create preview differ from the saved zone.
2026-09-19 09:30:31 +02:00
Maarten Lakerveld f3aba8f142 Activity timeline dots colored by zone in map panel (#54030)
* Color activity timeline dots by zone

Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.

* Color activity dots from the registry context

The zone color of a timeline entry takes the registry entries the overview
already consumes.
2026-09-19 08:23:36 +03:00
tzagimandYosi Levy 9c28c21792 Fix RTL bidi corruption of min/max temperature in more-info weather dialog (#54255)
* Fix RTL bidi corruption of min/max temperature in more-info weather dialog

* Fix direction also on attribution style

---------

Co-authored-by: Yosi Levy <[email protected]>
2026-09-19 08:15:50 +03:00
Jan-Philipp Benecke ccceddfd35 Add missing labels to previous and next tracked node in trace graph (#54244) 2026-09-19 08:13:19 +03:00
Jan-Philipp Benecke e633e5fb3e Introduce trace tree model for automation/script trace graphs (#54246)
* Introduce trace tree model for the automation/script trace graphs

* Process code review comments

* Process missed comments
2026-09-18 20:25:07 +02:00
WendelinandSimon Lamon 9a311b0518 Migrate md lists in 7 files (#54220)
* Fix network info secondary text size

* Migrate profile

* Migrate config-entries to list-base

* Revert wrapper a for ha-dropdown-item

* Revert wrapper a for ha-dropdown-item

* Copliot review

* Prettier

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-09-18 16:20:11 +03:00
c07f421f74 Regenerate statistics chart data when the formatters change (#54232)
* Regenerate statistics chart data when the formatters change

statistics-chart resolves its series names once and caches them in
_chartData, and it rebuilt only when the fetched statistics changed. A
rename therefore left the legend showing the old name.

The formatters are replaced as a set whenever the registries change, so
comparing formatEntityName identity is enough to catch it. This covers
the statistics graph card and the more-info history dialog; the energy
dashboard cards cache their series the same way and are handled
separately.

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

* Refresh statistics chart legend labels

Co-authored-by: MindFreeze <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MindFreeze <[email protected]>
2026-09-18 09:25:57 +02:00
Maarten Lakerveld 62acda8c08 New Map Panel (#54029)
* Add people, devices, and zones overview to the map card in panel view

When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.

* Resubscribe the map card's colors when the connection changes

The card kept its entity color subscription on whichever connection it
first saw. It now remembers that connection and subscribes anew when
hass brings a different one, as ha-map already does.

* Pause auto-fit before a deferred fit, and never select an empty tab

An explicit fit that had to wait for the engine or for a size did not
pause auto-fit until it ran, so an auto-fit queued in between could take
its place. The pause comes first now.

The overview's selected tab could be an empty, disabled one, showing an
empty list while another tab had content and leaving aria-selected on a
disabled button; the selection falls back to the first tab with items,
which the tab stop already did.

* Reuse the cards' show-more-info label in the map overview

The overview added its own copy of a label the other cards already have
translated; it uses the shared key and the duplicate is gone.

* Keep a focused marker clear of the overview

Focusing a person or zone fitted the full viewport, so the marker could
land under the overview: the start side on desktop, the bottom sheet on
phones. Fits now take viewport padding, both engines apply it per fit,
and the card passes the overview's size as padding on the side it covers,
honoring right-to-left layouts.

* Report activity that could not load, and list only events inside the window

A failed history request, or an instance without the history integration,
read as no recent activity; the panel now says the activity could not be
loaded. The first history sample is the state at the window's start, not
a change inside it, so a person who has not moved for days no longer shows
that old state as recent activity: samples before the window only seed
the previous state, for zones and people alike.

* Take the map card and overview colors from the registry context

The card no longer runs its own color subscription, and the overview reads
the registry entries for its zone list.

* Keep automatic fits clear of the overview

Only a selection fit knew about the drawer; the first fit and every
auto-fit used the whole viewport, so markers could land under the drawer.
ha-map takes a fit padding that automatic fits apply, and refits when it
changes, so the map settles once the drawer reports its size.

* Let the activity timeline scroll inside the drawer

On phones the drawer body is capped at 60vh and clips its overflow for the
collapse animation, so activity entries beyond that were unreachable. The
activity box now scrolls within the drawer.

* Keep the scale ruler and attribution beside the drawer on wide layouts

The bottom inset that lifts the map controls above the drawer only applied
on phones, so on desktop a tall drawer covered the scale ruler. ha-map gains
left and right insets for its bottom controls, and the card reserves the
drawer's width on the side it occupies.

* Extract the activity timeline parsing and test it

The window seeding, unavailable skipping and zone transition rules moved
into pure functions with tests, so a regression in the People or Zones
timeline no longer needs manual checking.

* Show the activity timeline as logbook rows

The overview rendered its own timeline items. It now uses ha-logbook-entry
in its narrow layout, so activity reads like the logbook everywhere: the
person, the zone they are in, and when. Arrivals and departures on the zone
tab are the same rows for the person who moved. The card hands hass to the
overview only for these rows.

* Let the resizable bottom sheet stay open at a minimum height

A persistent sheet stops at a pixel minimum instead of closing when dragged
down, can open at its content height within a configurable share of the
viewport rather than at least 55%, reports its height after opening and
resizing, and lets a host shrink the grab area below the handle for content
that sits right under it. The map overview uses all of this on phones; the
automation editor keeps the previous behavior.

* Show the overview as a bottom sheet on phones

The overview had its own collapsible panel with a handle button and swipe
handling. On phones it now renders inside ha-resizable-bottom-sheet in its
persistent mode, so it resizes like the automation editor's sheet and stops
at a strip with the tabs instead of closing. The sheet reports its height
for the map's insets and fit padding; wide layouts keep the floating panel.

* Let only the newest activity load show its result

Comparing the selected entity after the fetch let an older load for the
same entity pass when the user selected it, moved away, and came back,
so a stale result or failure flag could overwrite the newer one. Number
the loads and let only the most recent one commit its result.

* Include people without a location in the map overview

show_all builds the map entity list from located entities only, so people
whose location is currently unknown never reached the overview's people
list (with its no-location rows) or its zone activity history. Build a
separate overview list that adds those people back, while the map markers
keep the location filter.

* Move focus with the map overview detail view

Selecting an item swapped the list for the detail view (and back) without
moving focus, so keyboard and screen-reader users lost their place on the
removed element. Focus the detail heading when it opens, and restore focus
to the originating item when returning to the list.

* Keep the bottom sheet's minimum height correct after opening

minHeight is a pixel value converted to a viewport percentage and cached
once the sheet opens. Recompute it when the viewport changes (e.g. rotation)
so the cached percentage can no longer clip the persistent peek content, and
emit bottom-sheet-resized once a raised minimum has grown the sheet, so the
host updates its overlay padding instead of holding a stale value until the
next drag.

* Address Copilot review on the map overview

- Keep locationless people in the map entities so a marker appears once
  they are located, not only after a rebuild
- Restore focus to the active tab when a back navigation's row is on
  another tab
- Only reserve overview padding in panel layout

* Draw the cluster bubble and its tail with one shadow

The bubble carried its own box-shadow while the tail sat behind it, so the
bubble's shadow fell across the tail and showed a seam. The bubble and tail now
share one drop-shadow on the wrapper, so the tail reads as part of the bubble.

* Bubble a lone person or device in a zone

A zone group only bubbled with more than one member, so a single person or
device in a zone showed as a plain marker instead of a bubble at the zone.
Keyed (zone) groups now bubble even with one member, pinned to the zone; a
marker that is not in a zone still shows plainly.

* Recover located-later device trackers in the show_all overview

show_all freezes the located entities at generation, so a standalone device
tracker that gains a location afterwards stayed out of the Devices tab until
reload. Recover eligible standalone trackers alongside people, skipping those
that belong to a person.
2026-09-17 22:28:18 +01:00
Paul Bottein 193cbcbfac Allow removing orphaned areas in vacuum segment mapping (#54179)
* Allow removing orphaned areas in vacuum segment mapping

* Type event handler and use RTL-safe padding in vacuum area mapper

* Include the area name in the remove mapping label
2026-09-17 20:09:26 +00:00
Wendelin d43f7e58b6 Onboarding: Improve translation loading (#54176)
* onboarding: Improve translation loading

* Fix generic picker auth check

* fix _translationsReady default
2026-09-17 20:42:39 +02:00
Bruno Pantaleão Gonçalves fb419538c7 Fix login page safe area insets (#54229)
Center the login page inside the safe area

The authorize page only applied the top safe-area inset, so on devices
with side insets the card, password field, forgot password link and
help link were centered on the full viewport and clipped by the right
inset. Add the left and right insets to the content wrapper padding so
the auto margins center the card inside the safe area, and pad the
bottom inset as well.
2026-09-17 15:57:30 +02:00
c0ffeeca7 7c7549349a Add my link for serial panel under Connectivity (#54225) 2026-09-17 14:05:14 +02:00
Jan-Philipp Benecke 051f51dd26 Hide trigger index in trigger references unless triggered-by is edited (#54228) 2026-09-17 14:00:50 +02:00
Maarten Lakerveld 9a9d747899 Redesign map markers (#54025)
* Redesign map markers with zone circles and avatar cluster bubbles

Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.

* Frame person and device markers, ring the selected one

Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.

* Drop the editor's drag classes

The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.

* Keep dragged values on the zone page until they are saved

The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.

* Subscribe to entity colors only for maps that draw entities

Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.

* Address code review on the marker redesign

The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.

* Tell the user when a dragged zone edit fails to save

A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.

* Drop the numeric cluster badge styles, follow a replaced connection for colors

Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.

* Fall back to the palette color for entities without one, recolor zones on theme change

An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.

* Redraw markers on a theme change, and test the entity color assignment

Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.

* Count entity color subscriptions from the listener set

The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.

* Release the entity color subscription when a map stops drawing entities

A map whose entities were unset kept its registry subscription, holding
the shared stream open for nothing. It is released along with the
markers, and the three release sites share one helper.

* Address code review on the marker redesign, third round

The zone page's updated() override never called its base, so once
willUpdate stopped misusing super.updated the subscribe mixin had no hook
left to run hassSubscribe when hass arrives after connection; the override
calls super.updated now.

Saves for one zone run in order, each looking the entry up when it starts,
so a second response can replace what the first produced, and a failure
only drops the pending values its own request carried. A passive zone that
is not drawn no longer anchors a cluster bubble. A map whose entity list
empties releases the color subscription, not only one whose list is unset.

* Address code review: drop settled zone save queues

The per-zone save chain kept its last resolved promise for the life of the panel.

* Remove label dedupe

The doubled place labels are a tile issue, not part of the marker redesign,
and the population filter did nothing on the OSMF tiles, which fill in a
default population for every place. It moves to its own branch.

* Address code review: keep the label mode in cluster bubbles

Bubble avatars only carried the label text and picture, so an entity set to
the icon label mode showed initials once clustered and the attribute mode lost
its unit. The icon flag and unit now travel with the cluster data.

* Address code review: color map card entities from the shared palette

The card kept its own color dictionary, so an entity could have a different
color on every map. It now uses the palette that ha-map and the zone editor
already share, ordered by registry creation; a configured color still wins.

* Address code review: take entity colors from the registry context

The color module kept its own shared registry subscription with listener
counting and connection takeover. The lazy fullEntitiesContext already does
that for the whole app, so the module is now pure: it turns the registry
entries into a palette index, memoized per registry update. Maps ask for
the context only once they draw entities, so an editor's map, as in
onboarding, never requests the registry; the map card and the zone page
consume it directly.

* Address code review: one palette sequence for zones, persons and trackers

Per-domain sequences all started at slot 0, so the first person and the
first zone shared a color and a person could match the zone it was in.

* Address code review: recolor history trails when the registry changes

The trail colors are memoized with the registry entries as a key, like the
marker colors, so trails recolor with their markers when the registry
arrives or changes.

* Address code review: follow theme changes and keep bubble focus

The map card rebuilds its entity colors and trail colors when the theme
changes, ha-map redraws editable circles on a theme change, and the
locations editor rebuilds its cached marker elements. A focused cluster
bubble keeps focus on the bubble that replaces it, and a caller naming a
marker by aria-labelledby keeps that name.

* Address code review: recolor the map card from the themes context

The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.

* Address code review: keep the editor alert clear of the zoom control in RTL

The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.

* Fix the bubble tail covering the middle member

The tail is a rotated square whose upper half sits under the bubble. It was
painted on top, so in an open bubble it covered the bottom of the middle
member's frame and selected ring as a white triangle. Both tails now sit
behind their bubble.

* Narrow the locations editor to the themes context

ha-locations-editor consumed the whole uiContext but only reads themes, so
it rerendered on unrelated panel, sidebar, and UI-state changes. Transform
the context to Themes, following the pattern in hui-map-card.

* Keep focus when a cluster collapses to a single marker

Regrouping restored focus only to a replacement cluster icon, but a group
that becomes a single member is shown as a standalone marker with no icon,
so focus fell back to the document. Focus the member element in that case.
2026-09-17 12:26:11 +01:00
Bruno Pantaleão Gonçalves 5b99462fdf Fix notification drawer right safe area inset (#54230)
Apply the right safe-area inset to the full-width notification drawer

The drawer spans the full viewport whenever narrow is true (viewport
under 870px), but the right inset padding on the header and list was
only applied below a 450px media query. Between those widths the close
button ended up under the right inset. Reflect narrow as an attribute
and key the padding off it so the inset follows the same rule as the
drawer width.
2026-09-17 14:22:39 +03:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 14ef770c44 Overlay trace graph step navigation on the graph (#54227)
* Overlay trace graph step navigation on the graph

* Fix height problem

* Increase margin

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-17 14:20:00 +03:00
Przemysław Kłys 6a9fca2cb9 Fix logbook labels for restored button states (#54203)
* Fix logbook labels for unavailable timestamp entities

* Fix restored button context classification
2026-09-17 06:40:09 +00:00
Petar Petrov 55ba59074e Fix energy distribution home ring overflowing to 100% grid (#54191)
* Fix energy distribution home ring overflowing to 100% grid

Size the home circle from allocated used_solar, used_battery, and
used_grid instead of net used_total so misaligned hours cannot produce
a negative grid arc.

* Add gallery demo for energy distribution home ring

Mock the issue 54185 daily totals with realistic hourly PV, battery,
and grid statistics, including an export-lagged card that overflows
the old ring math.

* Remove gallery energy distribution demo from this PR

Keep the home-ring fix and its unit tests; drop the gallery page
that was only used for local browser verification.

* Keep a zero-consumption home ring as a plain border

When solar is configured but allocated flows are all zero, leave the
arcs unset so the card can show the home outline instead of zero-length
dashes on a borderless circle.

* Keep a defined zero low-carbon home arc

The energy distribution card mounts the home SVG when solar or
lowCarbon is defined. Omitting a 0 leftover hid grid and battery
arcs on a no-solar 100% high-carbon ring.
2026-09-16 15:49:29 +02:00
Maarten Lakerveld eeb674dd5a Show the selected light control on light group members (#54167)
* Show the selected light control on light group members

When a light group's more-info dialog is switched to color temperature, the
member tiles listed below now show a color temperature slider instead of
brightness, so the children can be adjusted on the same axis as the group.

The light control fires light-main-control-changed whenever its main control
changes, and more-info-content picks the member tile feature from that.
Members that do not support color temperature keep their brightness slider,
and the color mode keeps brightness since there is no tile feature for it.
Legacy group.* entities are unchanged.

* Hide member light control when group is in RGB color mode

There is no RGB tile feature, so showing a brightness slider on group
members while the group control is set to color is inconsistent. Show no
control in that case instead, per review feedback.
2026-09-16 15:44:11 +02:00
Jan-Philipp Benecke afe6e84e3c Make trace pages resizable two-pane layouts (#54216)
* Make trace pages resizable two-pane layouts

* Remove cast

* Remove cast
2026-09-16 16:06:56 +03:00
Bruno Pantaleão Gonçalves 3ddbc8f4e3 Communicate entity control to external bus (#54215)
* Communicate entity control to external bus

* Prefer target entity ids and report disconnecting calls

* Split and lowercase entity ids like Core does

* Keep entity control reporting best effort

* Assert wildcard entity ids are dropped
2026-09-16 15:25:32 +03:00
Jan-Philipp Benecke 216468cf50 Strip unreferenced duplicate trigger IDs (#54214) 2026-09-16 13:52:04 +02:00
Bram KragtenandClaude Opus 5 0ef3e9c71a Resolve statistic labels with structured entity names (#54202)
* Resolve statistic labels with structured entity names

getStatisticLabel resolved names through computeStateName, so every
statistic-shaped label — the energy dashboard cards, the sankey cards,
hui-statistic-card, chart series names and the energy configuration
dialogs — showed the flat friendly_name attribute while the rest of the
UI composed names from the device, area and floor.

It now asks hass.formatEntityName for the default composition, which
also makes the entity branch of computeEnergyLabel redundant.

The metadata fallback stays: external statistics have no entity to
resolve a name against.

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

* Keep the entity name shape out of the core chunk

recorder.ts sits in the core entry chunk, so importing DEFAULT_ENTITY_NAME
from compute_entity_name_display dragged the registry-aware formatters and
their dependencies in with it and blew the core bundle budget by 4.1%.

The constant and the name types have no runtime dependencies, so they move
to their own module; compute_entity_name_display re-exports them so nothing
else has to change.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-16 09:59:12 +03:00
Bram KragtenandClaude Opus 5 1266b0c87a Remove unused element registration imports (#54206)
ESLint cannot judge side-effect imports like `import "../../components/ha-card";`,
so dead component registrations accumulate unnoticed. A repo-wide scan matching
every side-effect import against the tag its module registers found 308 imports
whose element is never referenced anywhere in the importing file.

This removes the 84 that carry no registration risk: no other file instantiates
the tag without directly importing the defining module (checked for `<tag>`,
createElement/dynamicElement/customElements.get/whenDefined, and `custom:<tag>`
in Lovelace configs), and every affected module keeps at least one other
importer.

hui-weather-entity-row got ha-state-icon transitively through the dropped
hui-generic-entity-row import, so it now imports ha-state-icon directly, which
is the element it actually renders.

The gauge gallery page is left alone even though its .ts never names the tag: a
gallery page exists to register what its sibling .markdown demos render, and
that import is the registration.

The remaining 224 are deliberately out of scope. Their component is rendered
somewhere else without its own import, so they need the matching import added in
the same change.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-16 09:52:42 +03:00
Wendelin a695710beb Migrate md lists to new lists in 20 files - 2 (#54208)
* migrate to ha-list-base in 10 files

* migrate lists in 10 files
2026-09-15 19:23:46 +02:00
Wendelin f991efbc0a Migrate md lists to ha-list-base in 20 files (#54197)
* Migrated 10 files from md list to new lists

* migrate 10 files

* copilot review

* fix prettier
2026-09-15 19:06:53 +02:00
renovate[bot] 47306d1afd Update dependency intl-messageformat to v11.2.15 (#54213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 16:33:19 +00:00
karwostsandCopilot Autofix powered by AI 1692067392 Fix create helper dialog (#54211)
* Fix create helper dialog

* Ensure loading state resets after import

Wrap import and dirty tracking in try-finally to ensure loading state is reset.

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-15 18:26:53 +02:00
Paul Bottein 546315ca5b Share entity display and search label helpers across pickers (#54192) 2026-09-15 15:45:04 +03:00
renovate[bot] 7c98912b73 Update formatjs (#54162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 15:42:31 +03:00
Bram KragtenandClaude Opus 5 8c72e9c22d Remove unused Material Web wrappers (#54198)
`ha-outlined-button` and `ha-outlined-field` had no references anywhere
in the repository, so their custom elements were never registered at
runtime. `ha-sub-menu` was imported by five config panels, but no
template renders the tag.

Removes three `@material/web/**/internal/**` import sites; Material Web
is now only reached from chips, list, ripple and iconbutton.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-15 15:30:00 +03:00
Lucas Mindêllo de Andrade 892514ed21 Show toggle for fan entity only if TURN_ON or TURN_OFF is supported (#54188)
* fix(fan): keep toggle only if on off is supported

* feat(lovelace): add fan entity row

* code review adjusts
2026-09-15 15:26:14 +03:00
Bruno Pantaleão GonçalvesandClaude 06be6f433d Add more info opened and closed external bus messages (#54187)
Co-authored-by: Claude <[email protected]>
2026-09-15 09:29:34 +02:00
renovate[bot] e9a6e2936d Update dependency nanoid to v6 (#54189) 2026-09-15 07:13:27 +00:00
d3b7d787b4 Add trigger ID auto-generation and selection (#54130)
* Add trigger ID auto-generation and selection

* Fix trigger list index and descriptions

* Potential fix for blueprint crash

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

* Split cleanup

* Process Wendelin code review comments

* Process Marcin code review comments

* Remove unused controller config option

* Swap

* Fix minor review comments

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-09-15 09:07:03 +02:00
renovate[bot] 0662f8238f Update dependency hls.js to v1.7.3 (#54181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 06:17:17 +02:00
Jonas PeterssonandJonas Petersson 8a59516d8f Support glass break binary sensors (#53989)
* feat(security): support glass break binary sensors

* fix: add glass_break translation for device class override dropdown

* fix: add glass_break to generated device classes list

* fix: add glass_break icon to entity component icons for demo/fake data

---------

Co-authored-by: Jonas Petersson <[email protected]>
2026-09-14 17:44:15 +02:00
Petar Petrov 0afe6bb136 Cap unsupported config warning height so YAML fallback stays visible (#54177) 2026-09-14 16:52:31 +02:00
Bram KragtenandClaude Opus 5 61b487f995 Rename "For" to "For at least" and let duration inputs fill the row (#54175)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 15:35:18 +02:00
91abff2d0c Hide "for" on state condition when matching an attribute (#52710)
* Hide "for" on state condition when matching an attribute

The legacy state condition measures the `for` duration against
`last_changed`, which only updates on state changes, not attribute
changes. So `for` together with `attribute` is unreliable and core is
moving to reject it. Omit the `for` field when an attribute is selected,
drop any lingering duration value, and explain why via a helper.

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

* Keep "for" visible but disabled when matching an attribute

Removing the field left the explanation pointing at a control that was no
longer on screen. Keep "for" in the form, disable it while an attribute is
selected, and move the helper text under it.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-14 16:32:15 +03:00
Bram KragtenandClaude Opus 5 53d16c97fe Keep helper text readable on disabled fields (#54174)
Disabled fields dimmed their helper text to the disabled ink color, which
drops it to 2.6:1 contrast in light mode (5.7:1 when enabled). Helper text
is guidance about the control rather than part of its value, and it is often
the only thing explaining why a field is disabled, so keep it at its regular
contrast. The disabled state is still carried by the label, the value, and
the missing clear button.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 16:17:43 +03:00
Bram KragtenandClaude Opus 5 7493bfc86c Honor configured name in bootstrap formatEntityName (#54172)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 14:39:26 +02:00
infernalK ca1eb4ed7d Fix TypeError when hass.panels changes while rebuild() is in flight (#54171)
_updateRoutes() awaits this.rebuild() and then reads
this.hass.panels[this._currentPage].component_name without checking that
the entry still exists. hass.panels can be replaced again while the
rebuild() await is pending (e.g. multiple integrations or HACS-managed
Lovelace resources updating panels around startup), leaving
this._currentPage pointing at a panel that is no longer present. The
resulting TypeError breaks rendering of the current dashboard.

Guard the access after the await and bail out when the panel is gone:
willUpdate already re-triggers _updateRoutes for the newer hass.panels
value in that race, so returning early is safe and avoids the crash.

Fixes #54006
2026-09-14 15:38:16 +03:00
Petar PetrovandCopilot Autofix powered by AI c1dd17c4f9 Show Energy Now view for water and gas flow without power sensors (#54100)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-14 14:34:38 +02:00
Stu Kabakoff 5c74a84d01 show up to 5 discrete buttons on fan cards (#54137)
show up to 5 discrete buttons to fan card
2026-09-14 14:14:41 +02:00
shbatmandPetar Petrov a51d255ec3 Show disabled entities when filtering entities by device or area (#53444)
* Show disabled entities when filtering entities by device or area

The device and area filters on the entities config page resolve their
selection through search/related, which omits disabled entities. Opt in
to the new include_disabled_entities option there, so that filtering by
a device or area no longer hides that device's disabled entities.

The option is only set on the entities page, so every other consumer of
findRelated keeps its current behaviour.

* Update src/panels/config/entities/ha-config-entities.ts

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

* Update src/panels/config/entities/ha-config-entities.ts

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

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-09-14 13:25:35 +02:00
Petar Petrov 8c0dec0d9a Align stacked statistics lines across recorder gaps (#54166)
ECharts stacks a time axis by data index, so a statistic with a gap or a
different range shifted every stacked line after it. Merge the stacked line
datasets onto one timeline, let ECharts sample them after stacking, and hide
the padded null rows in the tooltip.
2026-09-14 13:49:48 +03:00
renovate[bot] 2c4b3c9109 Update dependency @rsdoctor/rspack-plugin to v1.6.4 (#54170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 13:34:56 +03:00
Petar Petrov c75bd2b5f0 Fix inconsistent grid labels in energy usage graph (#54168)
Make energy usage graph grid consumption label name-first
2026-09-14 12:07:18 +02:00
Wendelin 9b1665f5bf Refactor more-info update backup row (#54119)
* Extract and refactor update-backup

* run requests in parallel

* Separate fetches

* fix
2026-09-14 10:53:46 +02:00
Wendelin 603528bf47 Landing page: provide and use i18n context (#54128)
Migrate landing page to internationalization context
2026-09-14 10:45:08 +02:00
Wendelin 030b962e26 ha-auth use i18n context (#54127)
Use i18n context in ha-auth
2026-09-14 10:39:07 +02:00
Wendelin 85a34aeeee Onboarding: use localize context (#54126)
* Migrate onboarding to use localize context

* Load onboarding fragment translations after hass initialization.
2026-09-14 10:29:02 +02:00
iven 44dd2249b5 Fix calendar card collapsing with auto height (#54159)
* Fix calendar card collapsing with auto height

* Detect auto height from legacy layout_options
2026-09-14 09:52:04 +02:00
Yosi Levy f073a12cbd RTL chevron fixes (#53771)
* RTL chevron fixes

* Name changes

* Label fix

* Fix missing import
2026-09-14 09:43:41 +02:00
Yosi Levy 7dec4a57e8 Various RTL fixes (#54117)
* Various RTL fixes

* Fixes - no keyboard focus

* Fix style
2026-09-14 06:47:51 +02:00
Paulus SchoutsenandClaude aa78f368b1 Fix discovery list covering card corners on network page (#54157)
The ha-md-list inside the network discovery card draws its own surface
background. That square background hides the rounded bottom corners of
the card. Remove the list background so the card shows through.


Claude-Session: https://claude.ai/code/session_01PLf4NaCFm5tWguyUSmSAEx

Co-authored-by: Claude <[email protected]>
2026-09-14 06:47:01 +02:00
renovate[bot] 06901dedaa Update dependency lint-staged to v17.5.1 (#54155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:45:23 +02:00
renovate[bot] 43f194dad2 Update dependency vite to v8.3.0 (#54156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:45:04 +02:00
renovate[bot] e7acf97b1a Update babel monorepo to v8.0.5 (#54161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:44:12 +02:00
Paulus SchoutsenandClaude Opus 5 9409db7fda Show YAML-configured Modbus hubs in the Modbus panel (#54134)
* Show YAML-configured Modbus hubs in the connections panel

The backend now lists a hub configured in YAML as a connection of its
own, flagged with its source. Render those rows with the hub name and
the units it addresses, marked as configured in YAML, since there is no
config entry to link to.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018gAhZ9auwjmQaTg91efAD4

* Demo the Modbus YAML hub on a TCP device

One serial port cannot carry two links, so a YAML hub sharing the
RS-485 port was not a state the panel can reach. Put the hub on the TCP
device an integration also talks to, where two sockets are real.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018gAhZ9auwjmQaTg91efAD4

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-13 07:08:44 +00:00
dependabot[bot] d917df359f Bump home-assistant/actions/helpers/verify-version from a7c616ce81ccda50150bf1595786c71b1883fabb to 58bff37c8947f690ace498be413a9b78d6f30f93 (#54138)
Bump home-assistant/actions/helpers/verify-version

Bumps [home-assistant/actions/helpers/verify-version](https://github.com/home-assistant/actions) from a7c616ce81ccda50150bf1595786c71b1883fabb to 58bff37c8947f690ace498be413a9b78d6f30f93.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/a7c616ce81ccda50150bf1595786c71b1883fabb...58bff37c8947f690ace498be413a9b78d6f30f93)

---
updated-dependencies:
- dependency-name: home-assistant/actions/helpers/verify-version
  dependency-version: 58bff37c8947f690ace498be413a9b78d6f30f93
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-13 09:36:50 +03:00
Petar PetrovandCursor Agent 6dcca89185 Move history-graph card timeline names under the bars (#54102)
* Move history-graph card timeline names under the bars

Enable the same inside-labels layout as the History panel on the
Lovelace history-graph card. Custom entity names still override
the default Device ▸ Entity labels from computeHistory.

* Keep compact timeline rows when names are hidden

inside-labels only applies when the history-graph card is showing
names, and the timeline uses the taller row height only when those
under-bar labels are actually drawn.

* Match timeline row height to when inside labels are drawn

Use the same chunked || showNames condition as the axis options so
virtualized charts keep enough room for under-bar names. String YAML
names still apply when the entity is missing from current state;
structured names fall through to computeHistory instead of the entity ID.

* Preserve structured YAML names without a current state

formatEntityName still needs an entity id when the override is an
EntityNameItem. Use a stub state so device/text/entity name configs
apply even if the entity is missing from hass.states.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-12 13:28:53 +02:00
Wendelin 3f1b2ef65f Onboarding: lite internationalization context (#54125)
* Add internatinalization context to onboarding

* Use the context in welcome
2026-09-12 11:53:30 +02:00
Petar Petrov 34afbe37c3 Use ha-control-circular-slider in the light card (#54123)
Replace the legacy @thomasloven/round-slider brightness ring with the
in-house ha-control-circular-slider (already used by the thermostat and
humidifier cards). The card keeps its layout, center toggle button,
brightness readout, size, and all config options, so existing dashboards
are unaffected. The light card was the last consumer of round-slider, so
the dependency and its patch are dropped.
2026-09-12 11:53:05 +02:00
renovate[bot] 848e81313e Update dependency jszip to v3.10.2 (#54132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-12 09:32:56 +03:00
Paul Bottein 24e7bd306a Sync My redirects with my.home-assistant.io (#53956) 2026-09-11 16:13:45 +02:00
3791548067 Show every selector on the gallery selector page (#54121)
* Show the serial port selector in the gallery

The selector page had no serial_port entry, so the picker could only be
exercised against a running instance with real hardware attached.

Add it to "One of each", backed by a scan mock with one port per section
the picker groups by. The entry carries a flow context, and the page now
forwards that to ha-selector, because the recommended and not recommended
grouping is keyed off the domain a config flow supplies.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Answer the ingress panel list in the supervisor demo stub

The navigation picker waits for that collection before it enables itself,
so with the endpoint rejecting it stayed disabled for the whole demo and
gallery, with nothing on screen to say why.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Show every selector on the gallery selector page

Twenty-nine of the sixty selectors had no entry, so they could only be
looked at inside a config flow or a blueprint that happened to use one.

Add the missing entries to "One of each", with the mocks the assist and
statistic pickers need to return a list. A few carry a value or a config
because they render their fallback without one: the meta selector reads
the first key of its value, the period selector offers its custom-period
editor, and the voice list is fetched per engine and language.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Add new serial port configuration

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-11 17:11:41 +03:00
01cef9a99b Fix trigger 'for' choose (#53749)
* Fix trigger 'for' choose

* fix stub

* try something else

* merge duration-selector with create_duration_data

* handle negatives

* default ms to false for selector

* Handle hidden duration units in ha-duration-input

createDurationData grew three flags describing which fields the duration
input renders, but the input already owns enableDay, enableMillisecond,
enableSecond and allowNegative. Fold the units it does not render there
instead, so createDurationData keeps its single-argument signature.

This handles days and milliseconds symmetrically, and covers every
ha-duration-input consumer rather than only ha-selector-duration:
ha-form-positive_time_period_dict passes .data straight through without
enable-day and dropped days in exactly the same way.

A leading "-" negates the whole period in cv.time_period_str, so the
sign is now always distributed across all parts. The calendar trigger is
the only caller that can see a negative value and does its own sign
handling, so it takes the absolute value of every part instead of just
hours, and copies the duration first since createDurationData returns
dict values by reference.

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

* cleanup calendar trigger

* Refine duration type check for object values

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

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-11 16:34:29 +03:00
Steffen ProbstandClaude Fable 5 8eb179a0d9 Show pointer cursor on the whole track of the light card's round-slider (#53723)
The classic light card uses @thomasloven/round-slider, whose invisible
`.shadowpath` arc already accepts click/drag (nearest handle follows), but
only the handle dot showed a pointer cursor. Patch the dependency (same
mechanism as leaflet-draw/sortablejs) so the whole track shows the pointer,
and falls back to the default cursor when the slider is disabled or has no
handle. Adds a `--round-slider-path-cursor` override (defaults to
`--round-slider-handle-cursor`).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-09-11 16:10:07 +03:00
pcan08andClaude Sonnet 4.6 34e9b50920 Redesign integration card and remove double navigation (#52828)
* Redesign integration card to align with apps card style

Replace ha-button navigation links with a plain count badge, remove the
ha-icon-next chevron, make the entire card clickable on all content, and match the apps card hover/separator/spacing patterns.

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

* Extract integration card footer into ha-integration-card-footer

Move footer rendering logic (count badge, custom/cloud/yaml icons) and
their computations into a dedicated component, mirroring the
ha-integration-header split.

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

* Rename `ha-integration-header` to `ha-integration-card-header`

* Fix count badge vertical centering in integration card footer

* Replace hardcoded pixel values with spacing tokens

* Remove redundant physical margin properties

* Use consistent <span> elements for footer icons

* Remove dead CSS properties

* Add align-items: flex-start to secondary row

* Push integration card footer to bottom regardless of title length

* Migrate to Lit contexts

* Remove idvider in app and integration card

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-09-11 15:16:09 +03:00
Paul Bottein 2ebe452334 Add idle state and stop action to lawn mower (#53957) 2026-09-11 09:58:49 +00:00
renovate[bot] 3052989e9d Update dependency @rspack/core to v2.2.3 (#54118) 2026-09-11 11:39:13 +02:00
Preethi Pantangi fcab4004b2 Reserve min-height for view header to prevent layout shift (#54115) 2026-09-11 09:55:18 +01:00
renovate[bot] 4610adb368 Update dependency marked to v18.0.12 (#54114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 06:42:59 +02:00
renovate[bot] e0dba17b66 Update dependency typescript-eslint to v8.70.0 (#54112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 06:42:41 +02:00
karwosts eee0f9447b Format media selector in object selector (#54107)
Format media selector in object title
2026-09-10 18:34:03 +02:00
Paul Bottein 8d149a914f Add parent device to entity ID format (#54060)
* Add parent device to entity ID format editor

* Explain entity ID format examples with their context

* Share the entity name type list with the entity ID format editor

* Keep filter chip padding stable without a leading icon

* Show one entity ID format example at a time in a separate card

* Shorten entity ID format example names
2026-09-10 16:52:04 +03:00
Paul Bottein b2bb3e9d9d Nest child devices under their parent in device trees (#54063)
* Nest child devices under their parent in the entity tree

* Resolve device entries through shared helpers in the automation target browser

* Nest child devices under their parent when browsing automation targets

* Nest child devices under their parent in target details

* Skip child device rows already nested under their parent in area targets

* Fix expanding the automation target tree to an entity attached to an area

* Skip labeled child device rows already nested under a labeled parent

* Extract target sub-row computation and test child device nesting

* Use the inherited area when hiding filtered child devices

* Keep a parent device as a group row when a child device matches

* Keep child devices at the top level when their parent is disabled

* Drop disabled-parent special cases, core disables children with their parent
2026-09-10 15:46:28 +02:00
karwostsandCopilot Autofix powered by AI 13ef9712bc Use 'image_proxy' endpoint for image entity backgrounds (#54038)
* Use 'image_proxy' endpoint for image entity backgrounds

* Update image source URL with hassUrl method

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

* handle concurrent updates

* more robust

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-10 16:42:26 +03:00
Jump-333 40b27608c0 Add buttons style to target-humidity card feature (#53901)
* Add buttons style to target-humidity card feature

* Removed step configuration per-card

* Remove 'step' key from style list in translations

* Add target humidity step to humidity card feature

* Fixed a typo

* Updated style and style_list keys with localization keys.
2026-09-10 16:31:54 +03:00
Aidan Timson e0f03254b5 Show active alerts on Home dashboard (#53526)
* Show security alerts on Home

* Refresh Home security alerts

* Fix Home security alert state handling

* Fix Home alert fallback state

* Render Home alerts as individual cards

* Implement as summaries alerts and warnings in secondary for security
2026-09-10 16:03:49 +03:00
Petar Petrov 35dcba4f3f Fix login form ignoring password-manager autofill (#54076) 2026-09-10 14:31:57 +02:00
Paul Bottein ec4bfc9255 Add parent device to entity context (#53766)
* Add parent device to entity context

* Fix parent device context display edge cases

* Fix parent device context in suggestions and completions

* Order entity name parts by hierarchy

* Remove unused parent device missing string

* Add parent device to logbook entry context

* Show inherited area and parent device for device targets

* Test parent device search in the entity tree

* Show inherited area and parent device in the automation target header

* Move the device domain lookup out of target row item data

* Pass registries to memoized entity name lists

* Document parent device in logbook name detail
2026-09-10 13:38:39 +02:00
Maarten Lakerveld 3b9e026aff Edit zones and locations on the map engine instead of leaflet-draw (#54023)
* Edit zones and locations on the map engine instead of leaflet-draw

ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

MapEngine gains an optional editing capability (draggable markers and an
addEditableCircle primitive) plus panTo and containsLocation. Only the
MapLibre engine implements editing: the circle geometry plus a draggable
center marker and a draggable radius handle on the east edge, the radius
derived from the great-circle distance between them, redrawn per frame
while dragging. The handle has a touch-sized hit target and is a
keyboard-operable slider with an accessible name. The Leaflet engine
stays a viewing fallback: without WebGL2 an editor shows its locations
as static markers and a notice that this browser cannot edit on the map.

ha-map exposes editableLocations and fires
editable-location-moved/resized/clicked and editing-available-changed.
Locations are reconciled by id: position and radius changes move the
existing handles, while a changed icon, name, color, or editability
rebuilds the marker, so edits made in the form fields show up on the
map on either engine. ha-locations-editor becomes a thin layer over
that with its public contract unchanged, so its consumers need no
changes; it reads localize from the internationalization context instead
of taking hass.

The zone panel no longer zooms the map when a selection comes from the
map itself. Clicking or dragging a marker selects its list item, and
mwc-list reports that programmatic selection with the same "property"
request-selected event a click ends with. The two are told apart by
timing: a selection from the map has already set the active entry when
its event arrives, while a click's event arrives before the handler
sets it. Skipping the programmatic event also keeps the item selected,
which the shared request-selected helper would otherwise reset.

The layers property and the engine selector are removed along with the
leaflet-draw dependency and its patch: Leaflet is now purely the viewing
fallback, selected by WebGL2 support alone. The Leaflet engine keeps a
leafletMap field for the ha-map fit tests, which run on it in jsdom.

* Keep the retained circle data current while resizing

The engine keeps its source specifications to rebuild them after a style
swap; the editable circle's redraw updates that copy along with the map.

* Address code review on map editing

Editable markers, the circle center on both engines, and the static
fallback center activate on Enter and Space, as their button role
promises. Locations without a name get a localized one, since onboarding
and the location selector pass none. The resize handle's advertised
maximum follows the radius instead of being exceeded by it, and the
handle stays near the circle at high latitudes.

Clicking the active zone row zoomed to it again upstream but was
swallowed by the check that told a map-driven selection from a click; a
one-shot flag on selections made in code replaces that inference. The
engine rebuilds its sources from its own record on a style swap, so a
radius changed while the style was unloaded is not lost.

* Test the map editing path

The MapLibre engine's editable circle against the fake map: dragging the
center moves the circle and the handle and reports the drop, dragging the
handle resizes and snaps it back, arrow keys resize in steps committed on
release, the advertised maximum follows a larger radius, one update
echoing pre-drag values is ignored, the center activates by click, Enter
and Space but not after a drag, and removal clears markers, layers and
listeners.

ha-map's reconciliation of editable locations through a fake engine with
editing support: circles and markers are drawn, moved in place, rebuilt
when their appearance changes and removed when dropped, move, resize and
activation events carry the location id, and an engine without editing
support shows the locations statically.

* Leave zone row selection to the user

Clicking or dragging a zone on the map, and saving one in the dialog,
selected its row in code. That selection fires the same request-selected
event a click ends with, so the page had to tell the two apart, and the
row could not be shown selected and stay clickable at the same time: the
highlight never actually appeared. The list now only follows the user's
own clicks, which removes that bookkeeping along with the zoom that a
radius drag used to trigger.

* Hand marker elements back clean, and record sources before the style loads

MapLibre leaves its positioning class and transform on a marker element
after removal. Hosts like ha-locations-editor cache their elements and
hand them to whatever engine draws next: after a fatal WebGL failure that
is the Leaflet fallback, which shows the locations statically. A removed
element, and every element still on the map when the engine is destroyed,
is reset so it arrives as a plain element. The editable circle's center
gets the same treatment.

Custom sources and layers are now recorded when added and only inserted
into the map once the style has loaded. A circle moved while a swapped
style was still loading therefore reaches the record, and a swap in that
window carries it over; the deferred insert skips what the swap already
placed.

* Guard a keyboard resize against host updates like a drag

Arrow-key resizing changed the radius without arming the pre-edit guard
that pointer drags use, so a host re-render with the old radius while a
key was held, or its echo right after the commit, snapped the circle
back. A key resize now counts as a drag from the first key press until
the commit.

* Let the engine own the drag cursor, and tidy the fallback notice

The move cursor came from stylesheets keyed on whether a location may be
edited, so it showed on circles that cannot move and on the static
Leaflet fallback. The MapLibre engine now sets it on the elements it
actually made draggable and clears it when handing them back.

The fallback notice said the browser cannot edit locations, while
clicking the map to place one still works there; it now names what is
missing, dragging markers and resizing zones. The editor's
internationalization context is reactive, like its neighbours.

* Show the fallback notice only when a location is editable

The Leaflet fallback showed the notice for every editor, including ones
whose locations are all read-only, where nothing is missing.

* Drop the named icon's move cursor, and describe the fallback as a map state

The Leaflet fallback draws named location icons statically, but the
stylesheet still gave the ones marked editable a move cursor. The engine
sets that cursor on the elements it actually drags, so the rule goes.

The fallback notice blamed the browser, while MapLibre can also be
unavailable after a failed start or a lost WebGL context; it now
describes what the map offers. The resize handle's east point only guards
the pole itself, so its distance agrees with distanceMeters wherever a
zone can be.

* Announce the fallback notice, and let test frames redraw

The notice appears after the engine has been chosen, so helper text
would not reach a screen reader; it is an alert now. The engine test's
frame stub returned an id that landed in the engine's pending frame after
the callback had cleared it, so every redraw after the first was skipped.

* Show the fallback notice over the map, and keep handle clicks off the map

The notice followed a map that fills the editor's fixed height, so it
landed outside the box: clipped in onboarding, spilling into the form in
the location selector. It now sits over the top of the map, clear of the
zoom control.

The resize handle was the one marker whose click reached MapLibre's map
click, which a pin-on-click host takes as a request to move the location
there; the handle stops it like the other markers do.

* Share one circle geometry, and keep arrow keys on the handle

Drawing, fitting, and handle placement each converted meters to degrees
on their own, with different pole guards, so above 89.4 degrees the
resize handle left the drawn circle. They now share one conversion that
also agrees with distanceMeters; only the pole itself is guarded.

Arrow keys on the resize handle also reached MapLibre's keyboard handler
and panned the map while resizing; the handle keeps them.

* Drop the zone page's active entry

It only re-selected the row the user had just clicked, after the shared
request-selected helper had reset it, which fired the selection event a
second time and zoomed to the zone twice. The row highlight comes from
the list itself and nothing else read the value, so the list no longer
mirrors a selection at all.

* Make marker activation opt-in, and relabel handles on language change

Every editable marker was a focusable button whose activation fired an
event, whether or not the host did anything with it: the zone page and
the location selector do not. Activation is now opt-in per location; the
onboarding search results opt in, and a marker that does not act on it is
draggable without button semantics. The engine contract separates taking
pointer input from being a keyboard-focusable button for that.

Marker titles and handle labels are localized when drawn, so a language
change, or localization arriving after the first draw, redraws them.

* Own the accessibility attributes the engine sets, and use spherical circle geometry

setMarkerAccessibility only filled in what was missing and nothing removed
it, so a cached element rebuilt with a new title or a different role kept
the old attributes. The helper now records what it set, replaces exactly
that on a rebuild, and clears it when the element leaves the map, while a
caller's own attributes stay untouched.

The flat meters-to-degrees conversion broke down for large circles near
the poles, wrapping longitude and producing latitudes beyond 90. Handle
placement and the polygon now use the great-circle destination, and the
fitted bounds a spherical box that spans all longitudes once the circle
reaches a pole, so all three agree with distanceMeters everywhere.
2026-09-10 11:31:06 +02:00
Petar Petrov 569bcc01da Fix frozen solar-to-grid dot and phantom solar dot in energy distribution card (#54085) 2026-09-10 10:20:38 +01:00
Petar PetrovandCursor Agent 8ea28508c3 Move history timeline names under the bars (#54086)
Co-authored-by: Cursor Agent <[email protected]>
2026-09-10 09:58:48 +01:00
Dominik c3e341bdb3 Unify trend-graph and temperature-forecast card-features (#54037)
Unify trend-graph and temperature-forecast
2026-09-10 09:01:57 +03:00
Paulus SchoutsenandClaude 58de8df46a Prevent closing the backup restore dialog by clicking outside (#54095)
Prevent scrim close on backup restore dialog

Clicking outside the restore dialog dismissed it unless the encryption
key field was dirty. Restoring a backup is destructive, so guard the
dialog at every step.


Claude-Session: https://claude.ai/code/session_017eZQ6BTz6MervuG3Zb8RLp

Co-authored-by: Claude <[email protected]>
2026-09-10 08:15:47 +03:00
karwosts 3447e1d862 An empty placeholder for entity-filter-badge in edit mode (#54096) 2026-09-10 08:15:02 +03:00
renovate[bot] 105a2a2423 Update Node.js to v24.21.0 (#54097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-10 07:00:24 +02:00
Larai 27efa82a89 Fix accessible names for analytics consent switches (#54083) 2026-09-09 18:23:13 +02:00
Josef Zweck 19cf7f2613 Allow shared translations for subentry abort (#53964)
* Allow shared translations for subentry abort

* load translation
2026-09-09 18:18:41 +02:00
Josef Zweck fcd25e41a0 Make sure we load shared translations in config flow (#54084)
* Make sure we load shared translations in config flow

* load translation for specified domain
2026-09-09 18:18:00 +02:00
Aidan TimsonandPetar Petrov f942a5afe5 Add visibility condition reordering (#53252)
Co-authored-by: Petar Petrov <[email protected]>
2026-09-09 14:49:01 +02:00
Aidan TimsonandCopilot Autofix powered by AI 89b745b851 Add contextual descriptions to rows in card condition editor (#52817)
* Add row targets UI to entity state and numeric state in card condition editor

* Use automation descriptions instead of target ui

* Type check

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

* Format condition editor

* Fix contextual condition descriptions

* Resolve state references in condition descriptions

* Share automation condition headers with visibility conditions

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-09 15:29:42 +03:00
Maarten LakerveldandCopilot Autofix powered by AI ac582cbfbb Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet (#54022)
* Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet

ha-map no longer drives Leaflet directly. All primitive operations -
camera, HTML element markers, meter-radius circles, history paths,
clustering with a pluggable icon builder, scale ruler, dark mode - go
through a MapEngine interface, and the engine is selected at runtime:

- MapLibreMapEngine renders the vector base map natively where WebGL2 is
  available, without Leaflet in the loop: continuous fractional zoom,
  DOM markers synced to the basemap every frame, GeoJSON zone and
  accuracy circles below the labels, GeoJSON history trails with hover
  popups, and a pixel-distance cluster grid recomputed when the camera
  settles. Dark mode swaps the style while carrying the custom layers
  over, and rotation and pitch stay disabled for north-up dashboards.
- LeafletMapEngine wraps the existing behavior unchanged (vector tiles
  through the maplibre adapter with the raster fallback, markercluster)
  for browsers without WebGL2 - the legacy floor includes iOS 12-14 and
  kiosk browsers without hardware acceleration - and for
  ha-locations-editor, which manages raw Leaflet layers with
  leaflet-draw and declares engine=leaflet.

A permanently lost WebGL context rebuilds the map on the Leaflet engine
instead of leaving a dead canvas. Zoom levels keep Leaflet semantics
across engines. MapLibre's stylesheet is shipped to /static/map for the
native engine's controls and popups.

* Enhance keyboard accessibility for icon click

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

* Fix dark mode style application logic

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

* Address code review on the map engine

A fatal engine event during setup was dropped because the in-flight
setup owned the loading flag; the fallback request is now recorded and
setup switches to Leaflet once init settles. Entity markers accept Enter
and Space, as both engines make them focusable buttons. History points
show their popup on tap as well as hover, since touch has no hover.

Tests cover the engine choice and fallback paths, including a fatal
event mid-setup, and the MapLibre engine itself against a fake map:
style swaps, queued layer work, custom layer carry-over, failed style
requests, token refusal recovery, context-loss grace, and clustering.

* Address second code review round on the map engine

A cluster whose members share a spot, or that sits at maximum zoom,
could never be opened: fitting its bounds changed nothing and the next
regroup recreated it. Activating such a cluster now opens it: all its
members are shown in a bubble with a tail pointing at their spot, each
reachable on its own, until the map moves again.

An engine whose init failed, or was abandoned by a disconnect or a fatal
event during setup, was never destroyed and could keep a WebGL context;
setup now destroys any engine that did not become the active one.

The Leaflet engine sizes the cluster element like MapLibre does, so
cluster bubbles no longer shrink to their text.

* Keep the engine's own record of its map sources and layers

Carrying zone circles and history paths over a style swap relied on
MapLibre serializing the outgoing style into transformStyle's previous
argument. The engine now keeps the source and layer specifications it
added and rebuilds them into the new style itself, so the carry-over no
longer depends on what MapLibre hands over, and a missing previous style
cannot drop them.

* Address review: setup teardown, marker input, cluster focus

An engine still setting up could not be torn down: ha-map only held it
once init resolved, so removing the element mid-load kept its WebGL
context alive and left the loading guard set, ignoring a reconnect. The
engine being set up is now tracked and destroyed on disconnect, a stale
setup notices it was superseded, and the MapLibre engine settles a
pending init when destroyed.

Non-interactive markers let pointer input through, as they do on
Leaflet. Opening a cluster from the keyboard moves focus to its first
member, and a member that has focus when the bubble closes hands it to
the icon that replaces it.

* Tear down an engine that fails while still setting up

A fatal event during setup only flagged the fallback and waited for
init to settle, which relied on MapLibre still firing style.load. The
engine being set up is destroyed instead, which settles its init, so the
setup hands over to Leaflet without depending on the failed engine.

* Make history point popups readable on MapLibre

MapLibre's stylesheet is linked into the map's shadow root and wins over
the component styles on equal specificity, property by property: its
white popup background applied while our white text color did too,
leaving the timestamp invisible. The popup rules now carry !important,
as the Leaflet tooltip rules already do for the same reason.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-09 15:26:28 +03:00
Petar Petrov dab85a9239 Add show_values toggle to all Sankey cards (#54075) 2026-09-09 12:09:44 +01:00
Wendelin 29e0e9db87 Add paste to add automation element by target (#54052) 2026-09-09 11:08:37 +01:00
Wendelin 3ce90a7a68 Fix update last backup description (#54066) 2026-09-09 10:59:17 +02:00
dependabot[bot] d6260482e0 Bump svgo from 4.0.2 to 4.1.0 (#54078)
Signed-off-by: dependabot[bot] <[email protected]>
2026-09-09 08:49:27 +01:00
ca24b16702 Add show_values toggle to energy and power Sankey cards (#53925)
* Add show_values toggle to energy and power Sankey cards

Adds a boolean config option that renders each node's value directly
on its diagram label (e.g. "Solar\n3.4 kWh"), stripping HTML from the
valueFormatter output for ECharts plain-text labels.

Fix show_values label alignment on Sankey nodes

Collapse internal whitespace in valueFormatter output after stripping
HTML tags, so "7.7\n      kWh" becomes "7.7 kWh" on a single line.

* Address review feedback on show_values

Removes the HTML-tag-stripping regex from the label formatter (an
incomplete-sanitization pattern flagged by CodeQL); the value
formatter never returns HTML and the label renders as plain ECharts
canvas text, so no stripping is needed. Splits the vertical
label-sizing measurement on all whitespace instead of literal spaces,
so the newline-separated value line is measured correctly. Scopes
show_values to the energy and power Sankey card configs instead of
the shared Sankey base, and hides the option in the card editor for
water and water-flow cards, since those cards don't support it.

* Apply batched suggestions from code review

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

* Format Sankey card templates with Prettier

Restore Prettier wrapping around the show_values template bindings so CI lint:prettier passes.

* Fix vertical Sankey value labels wrapping on thin nodes

Unicode isolates around show_values text were treated as wrap characters, and font sizing ignored ECharts minMargin, so short names like TV with small values split mid-word. Size labels to the inner wrap width and keep LTR with a mark instead of isolates.

* Reserve extra bottom space for vertical Sankey values

When show_values is on, node labels can be three lines (name, number, unit). Increase the vertical series bottom inset from the single-line 25px so the lowest row is not clipped.

* Keep locale grouping spaces in Sankey value labels

Trim formatter output without collapsing internal whitespace, so narrow no-break thousands separators are not turned into wrap points. Measure the longest word on regular spaces and newlines only.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
2026-09-09 09:32:27 +03:00
Wendelin 380bcb8315 Fix automation trigger/condition platform default behavior (#54062)
* Fix platform behavior default

* Fix race condition by updating target entity count and setting default behavior
2026-09-09 08:28:23 +03:00
karwosts 9902924ffa Use heading elements for heading-card (#54068) 2026-09-09 08:27:45 +03:00
Paulus SchoutsenandClaude Opus 5 cb0c03ab8b Add a Modbus panel (#54036)
* Add a Modbus panel

The Modbus integration exposes the connections it keeps open over
`modbus/connections/list`. This surfaces them under Connectivity, mirroring the
Serial panel: one entry per physical device, grouped by whether the link is up,
naming the config entries holding units on it.

Because one device is shared by every integration talking to it, the panel is
where a shared RS-485 bus or gateway becomes visible — the two integrations on
`/dev/ttyUSB1` cannot be seen from either integration's own page.

The Serial panel gains the other half of that: a port carrying a Modbus
connection now points at the Modbus panel. The two are matched on the device
path, which Modbus stores as configured, so both the scanned and the resolved
path are compared. Reaching Modbus is not required for the Serial panel to
load.

Demo fixtures come with it, so the connections and the shared bus can be seen
without a Home Assistant instance. The RS-485 port the Serial demo already
lists gains the two integrations that hold units on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FjmjZsxhdj9ekGmhFHXg3p

* Modbus: show link state as an indicator, not a verdict

A closed link is not a fault. The library opens one when an integration next
polls, and a device is free to drop an idle one in between, so grouping the
connections into Connected and Disconnected cards behind a warning icon
reported trouble where there was none.

One list now, ordered by address alone, with a per-row indicator. A link
opening or closing no longer moves a row between cards, and the summary is a
count rather than a health verdict.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FjmjZsxhdj9ekGmhFHXg3p

* Modbus: a serial port is open, not connected

Opening a serial port proves a device node exists; nothing on the wire has to
answer. Only a network connection performs a handshake, so "Connected" was
promising more than a serial row can know. Those rows read Open and Closed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FjmjZsxhdj9ekGmhFHXg3p

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-09 07:08:22 +02:00
Aidan Timson 64cda9e0e7 Clarify companion skill selection before frontend review (#54067) 2026-09-08 20:21:56 +02:00
514 changed files with 25317 additions and 7471 deletions
+6 -1
View File
@@ -7,6 +7,12 @@ description: Home Assistant frontend PR and review guidance, including implement
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Review Preparation
1. Establish the review scope from the diff and the behavior changed by it.
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
@@ -116,4 +122,3 @@ For user-facing changes, establish the existing design context as part of fronte
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
+15
View File
@@ -7,13 +7,17 @@ body:
value: |
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
Please read our [issue reporting guide][reporting-issues] before submitting a report.
If you have a feature or enhancement request for the frontend, please [start a discussion][fr] instead of creating an issue.
**Please do not report issues for custom cards.**
[fr]: https://github.com/orgs/home-assistant/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
[reporting-issues]: https://www.home-assistant.io/help/reporting_issues/
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please verify that you've followed these steps
@@ -30,18 +34,21 @@ body:
value: |
## The problem
- type: textarea
id: problem
validations:
required: true
attributes:
label: Describe the issue you are experiencing
description: Provide a clear and concise description of what the bug is.
- type: textarea
id: expected_behavior
validations:
required: true
attributes:
label: Describe the behavior you expected
description: Describe what you expected to happen or it should look/behave.
- type: textarea
id: steps_to_reproduce
validations:
required: true
attributes:
@@ -59,6 +66,7 @@ body:
value: |
## Environment
- type: input
id: core_version
validations:
required: true
attributes:
@@ -67,18 +75,21 @@ body:
description: >
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
- type: input
id: last_working_version
attributes:
label: What was the last working version of Home Assistant Core?
placeholder: core-
description: >
If known, otherwise leave blank.
- type: input
id: browser
attributes:
label: In which browser are you experiencing the issue?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: input
id: os
attributes:
label: Which operating system are you using to run this browser?
placeholder: macOS Big Sur (1.11)
@@ -90,6 +101,7 @@ body:
# Details
- type: textarea
id: entity_state
attributes:
label: State of relevant entities
description: >
@@ -98,6 +110,7 @@ body:
information in the Details view of the More info dialog.
render: txt
- type: textarea
id: configuration
attributes:
label: Problem-relevant frontend configuration
description: >
@@ -107,6 +120,7 @@ body:
passwords, private URLs and other credentials.
render: yaml
- type: textarea
id: javascript_errors
attributes:
label: JavaScript errors shown in your browser console/inspector
description: >
@@ -114,6 +128,7 @@ body:
browser console/inspector please provide them.
render: txt
- type: textarea
id: additional_information
attributes:
label: Additional information
description: >
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
+35
View File
@@ -0,0 +1,35 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
);
await checkBlockingLabels({ github, context, core });
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+1 -1
View File
@@ -1 +1 @@
24.20.0
24.21.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -37,7 +37,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
## Project Skills
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
+2
View File
@@ -81,6 +81,8 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
+2
View File
@@ -1,6 +1,7 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { modbusFixtures } from "./modbus/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
@@ -16,6 +17,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
modbusFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
+2
View File
@@ -1,6 +1,7 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockModbus } from "./modbus/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
@@ -12,6 +13,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
const MOCKS = [
mockBluetooth,
mockSerial,
mockModbus,
mockMatter,
mockMqtt,
mockZwaveJs,
@@ -0,0 +1,47 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const FLEXIT_ENTRY_ID = "mock-flexit";
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
export const FRONIUS_ENTRY_ID = "mock-fronius";
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("flexit", "Flexit", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "device",
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
},
{
type: "device",
entry: configEntry(
SOLAREDGE_ENTRY_ID,
"solaredge_modbus",
"SolarEdge SE7K"
),
},
{
type: "device",
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
},
],
};
@@ -0,0 +1,42 @@
import type { ModbusConnection } from "../../../../../src/data/modbus";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import {
FLEXIT_ENTRY_ID,
FRONIUS_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "./fixtures";
const CONNECTIONS: ModbusConnection[] = [
// Two integrations on one RS-485 bus, which is what the panel exists to show
{
endpoint: ["serial", RS485_PORT],
connected: true,
source: "config_entry",
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
},
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [1] },
},
// A second inverter behind a gateway that is not answering
{
endpoint: ["tcp", "modbus-gateway.local", 502],
connected: false,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [2] },
},
// A YAML hub keeps a link of its own to a device an integration also talks to
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "yaml",
units: { modbus_hub: [10, 11] },
},
];
export const mockModbus = (hass: MockHomeAssistant) => {
hass.mockWS("modbus/connections/list", () => ({ connections: CONNECTIONS }));
};
+26 -2
View File
@@ -1,5 +1,12 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
// The RS-485 port below carries the Modbus connections, so both sides name the
// same config entries.
import {
FLEXIT_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "../modbus/fixtures";
const PORTS: SerialPortUsage[] = [
{
@@ -55,7 +62,7 @@ const PORTS: SerialPortUsage[] = [
discovery_flows: [],
},
{
device: "/dev/ttyUSB1",
device: RS485_PORT,
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
@@ -68,7 +75,24 @@ const PORTS: SerialPortUsage[] = [
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [],
consumers: [
{
kind: "config_entry",
title: "Flexit Nordic S4",
active: true,
domain: "flexit",
config_entry_id: FLEXIT_ENTRY_ID,
slug: null,
},
{
kind: "config_entry",
title: "SolarEdge SE7K",
active: true,
domain: "solaredge_modbus",
config_entry_id: SOLAREDGE_ENTRY_ID,
slug: null,
},
],
discovery_flows: [],
},
{
+1
View File
@@ -13,6 +13,7 @@ const HARDWARE_INFO: HardwareInfo = {
hassio_board_id: "green",
manufacturer: "homeassistant",
model: "green",
revision: null,
},
dongle: null,
config_entries: [],
+30 -22
View File
@@ -32,11 +32,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "d5369777",
stage: "stable",
state: "started",
system_managed: false,
update_available: false,
url: "https://github.com/music-assistant/home-assistant-addon",
version: "2.6.3",
@@ -53,11 +53,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "5c53de3b",
stage: "stable",
state: "started",
system_managed: false,
update_available: false,
url: "https://esphome.io/",
version: "2025.7.3",
@@ -114,13 +114,14 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
boot_config: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
dns: [],
docker_api: false,
documentation: false,
full_access: false,
@@ -133,8 +134,10 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
host_ipc: false,
host_network: false,
host_pid: false,
host_uts: false,
ingress_entry: null,
ingress_panel: false,
ingress_port: null,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
@@ -148,13 +151,16 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services_role: [],
services: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
uart: false,
udev: false,
usb: false,
video: false,
watchdog: true,
webui: null,
});
@@ -181,11 +187,14 @@ const ADDON_STATS: HassioStats = {
blk_read: 12300000,
blk_write: 4500000,
cpu_percent: 1.4,
cpu_system_usage: 1420000000000,
cpu_usage: 19800000000,
memory_limit: 3900000000,
memory_percent: 4.2,
memory_usage: 163000000,
network_rx: 8900000,
network_tx: 2300000,
online_cpus: 4,
};
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
@@ -209,9 +218,13 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
wait_boot: 5,
timezone: "Europe/Amsterdam",
logging: "info",
auto_update: true,
country: "NL",
debug: false,
debug_block: false,
detect_blocking_io: false,
diagnostics: true,
feature_flags: {},
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/music-assistant/home-assistant-addon",
@@ -224,22 +237,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
if (msg.endpoint === "/addons") {
const data: HassioAddonsInfo = {
addons: DEMO_ADDONS,
repositories: [
{
slug: "d5369777",
name: "Music Assistant",
source: "https://github.com/music-assistant/home-assistant-addon",
url: "https://github.com/music-assistant/home-assistant-addon",
maintainer: "Music Assistant",
},
{
slug: "5c53de3b",
name: "ESPHome",
source: "https://github.com/esphome/home-assistant-addon",
url: "https://esphome.io/",
maintainer: "ESPHome",
},
],
};
return data;
}
@@ -264,6 +261,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hostname: "homeassistant",
logging: "info",
machine: "green",
machine_id: "0123456789abcdef0123456789abcdef",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
@@ -301,6 +299,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
update_available: false,
version: "18.2",
version_latest: "18.2",
version_pending: null,
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
@@ -334,8 +333,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
const data: NetworkInfo = {
interfaces: [
{
connected: true,
mac: "aa:bb:cc:dd:ee:ff",
primary: true,
privacy: false,
interface: "eth0",
enabled: true,
type: "ethernet",
@@ -354,10 +354,18 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
gateway: "172.30.32.1",
interface: "hassio",
},
host_internet: true,
supervisor_internet: true,
};
return data;
}
if (msg.endpoint === "/ingress/panels") {
// The navigation picker waits for this collection before it enables
// itself, so it has to answer even when no add-on exposes a panel.
return { panels: {} };
}
if (msg.endpoint === "/store/reload") {
return null;
}
-1
View File
@@ -3,7 +3,6 @@ import { customElement, property } from "lit/decorators";
import "../../../src/components/ha-card";
import "../../../src/dialogs/more-info/more-info-content";
import "../../../src/state-summary/state-card-content";
import "../ha-demo-options";
import type { HomeAssistant } from "../../../src/types";
import { computeShowNewMoreInfo } from "../../../src/dialogs/more-info/const";
-4
View File
@@ -126,7 +126,6 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -171,7 +170,6 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -189,7 +187,6 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -207,7 +204,6 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
-2
View File
@@ -1,9 +1,7 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import "./components/page-description";
@customElement("ha-demo-options")
class HaDemoOptions extends LitElement {
+2 -4
View File
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
@@ -682,10 +683,7 @@ class HaGallery extends LitElement {
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
formatEntityName: computeEntityNameDisplayWithoutContext,
} as unknown as HomeAssistant;
}
@@ -57,6 +57,7 @@ This component is based on the webawesome input component.
| invalid | Boolean | false | Marks the input as invalid. |
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
| pattern | String | - | A regular expression pattern to validate input against. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
+257 -1
View File
@@ -21,6 +21,8 @@ import "../../../../src/components/ha-settings-row";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import { StatisticMeanType } from "../../../../src/data/recorder";
import type { SerialPort } from "../../../../src/data/usb";
import {
showDialog,
type ShowDialogParams,
@@ -293,9 +295,72 @@ const LABELS: LabelRegistryEntry[] = [
},
];
const serialPort = (port: Partial<SerialPort>): SerialPort => ({
device: "/dev/ttyUSB0",
resolved_device: null,
serial_number: null,
manufacturer: null,
description: null,
matching_integrations: [],
present: true,
...port,
});
// One port per section the picker groups by, relative to the "zha" domain the
// serial port selector below is given as its flow context
const SERIAL_PORTS: SerialPort[] = [
serialPort({
device:
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
description: "SkyConnect v1.0",
manufacturer: "Nabu Casa",
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
vid: "10C4",
pid: "EA60",
matching_integrations: ["zha"],
}),
serialPort({
device: "esphome-hass://01JQ8Z5X9WQ0/?port_name=UART0",
}),
serialPort({
device: "socket://192.168.1.10:6638",
}),
serialPort({
device: "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_AB0KVD1L-if00-port0",
description: "FT232R USB UART",
manufacturer: "FTDI",
serial_number: "AB0KVD1L",
vid: "0403",
pid: "6001",
}),
serialPort({
device: "/dev/ttyS0",
description: "ttyS0",
manufacturer: "Intel",
}),
serialPort({ device: "/dev/ttyAMA0" }),
serialPort({
device:
"/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0",
description: "CP2102 USB to UART Bridge Controller",
manufacturer: "Silicon Labs",
serial_number: "0001",
vid: "10C4",
pid: "EA60",
matching_integrations: ["matter"],
}),
];
const SCHEMAS: {
name: string;
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
input: Record<
string,
| (BlueprintInput & {
required?: boolean;
context?: Record<string, unknown>;
})
| null
>;
}[] = [
{
name: "One of each",
@@ -320,6 +385,13 @@ const SCHEMAS: {
},
duration: { name: "Duration", selector: { duration: {} } },
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
selector: { serial_port: {} },
// A config flow passes its own domain as context, which is what the
// picker groups recommended and not recommended ports by
context: { domain: "zha" },
},
number_box: {
name: "Number Box",
selector: {
@@ -504,6 +576,99 @@ const SCHEMAS: {
},
},
},
addon: { name: "Add-on", selector: { addon: {} } },
areas_display: {
name: "Areas display",
selector: { areas_display: {} },
},
assist_pipeline: {
name: "Assist pipeline",
selector: { assist_pipeline: {} },
},
automation_behavior: {
name: "Automation behavior",
selector: { automation_behavior: { mode: "trigger" } },
},
backup_location: {
name: "Backup location",
selector: { backup_location: {} },
},
button_toggle: {
name: "Button toggle",
selector: {
button_toggle: {
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
},
},
condition: { name: "Condition", selector: { condition: {} } },
conversation_agent: {
name: "Conversation agent",
selector: { conversation_agent: {} },
},
country: {
name: "Country",
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
},
entity_name: {
name: "Entity name",
selector: { entity_name: { entity_id: "light.bedroom" } },
},
file: {
name: "File",
selector: { file: { accept: "image/png,image/jpeg" } },
},
language: { name: "Language", selector: { language: {} } },
navigation: { name: "Navigation", selector: { navigation: {} } },
numeric_threshold: {
name: "Numeric threshold",
selector: { numeric_threshold: {} },
},
period: {
name: "Period",
selector: {
period: {
options: ["today", "yesterday", "this_week", "this_month"],
},
},
// Without a value that matches one of the options the selector offers
// its custom-period editor instead of the list
default: { calendar: { period: "day" } },
},
selector: {
name: "Selector",
selector: { selector: {} },
default: { text: {} },
},
state_class: { name: "State class", selector: { state_class: {} } },
statistic: { name: "Statistic", selector: { statistic: {} } },
stt: { name: "Speech-to-text", selector: { stt: {} } },
theme: { name: "Theme", selector: { theme: {} } },
timezone: { name: "Time zone", selector: { timezone: {} } },
trigger: { name: "Trigger", selector: { trigger: {} } },
tts: { name: "Text-to-speech", selector: { tts: {} } },
tts_voice: {
name: "Text-to-speech voice",
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
},
ui_action: { name: "UI action", selector: { ui_action: {} } },
ui_clock_date_format: {
name: "Clock date format",
selector: { ui_clock_date_format: {} },
},
ui_color: { name: "UI color", selector: { ui_color: {} } },
ui_state_content: {
name: "State content",
selector: { ui_state_content: { entity_id: "light.bedroom" } },
},
ui_time_format: {
name: "Time format",
selector: { ui_time_format: {} },
},
},
},
{
@@ -605,8 +770,98 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
mockFloorRegistry(hass, FLOORS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
hass.addTranslations({
"component.matter.title": "Matter",
"component.zha.title": "Zigbee Home Automation",
});
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "usb"],
},
});
hass.mockWS("auth/sign_path", (params) => params);
hass.mockWS("media_player/browse_media", this._browseMedia);
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
hass.mockWS("recorder/list_statistic_ids", () => [
{
statistic_id: "sensor.energy_consumption",
statistics_unit_of_measurement: "kWh",
source: "recorder",
name: null,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
},
{
statistic_id: "sensor.outside_temperature",
statistics_unit_of_measurement: "\u00b0C",
source: "recorder",
name: null,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
},
]);
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [
{
id: "pipeline_home",
name: "Home Assistant",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.cloud",
stt_language: "en-US",
tts_engine: "tts.cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
],
preferred_pipeline: "pipeline_home",
}));
hass.mockWS("conversation/agent/list", () => ({
agents: [
{
id: "conversation.home_assistant",
name: "Home Assistant",
supported_languages: "*",
},
{
id: "conversation.openai",
name: "OpenAI Conversation",
supported_languages: ["en"],
},
],
}));
hass.mockWS("stt/engine/list", () => ({
providers: [
{
engine_id: "stt.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/list", () => ({
providers: [
{
engine_id: "tts.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/voices", () => ({
voices: [
{ voice_id: "JennyNeural", name: "Jenny" },
{ voice_id: "GuyNeural", name: "Guy" },
],
}));
}
public provideHass(el) {
@@ -776,6 +1031,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
.context=${value!.context}
.key=${key}
.label=${this._label ? value!.name : undefined}
.value=${data[key] ?? value!.default}
+4 -3
View File
@@ -21,7 +21,8 @@ const ENTITIES = [
state: "on",
attributes: {
friendly_name: "Dining Room",
supported_features: 1,
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 100,
},
},
@@ -30,7 +31,7 @@ const ENTITIES = [
state: "off",
attributes: {
friendly_name: "Dining Room",
supported_features: 1,
supported_color_modes: ["brightness"],
},
},
{
@@ -38,7 +39,7 @@ const ENTITIES = [
state: "unavailable",
attributes: {
friendly_name: "Lost Light",
supported_features: 1,
supported_color_modes: ["brightness"],
},
},
];
+82 -2
View File
@@ -12,7 +12,10 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
GridCardConfig,
TileCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -163,6 +166,39 @@ const ENTITIES = [
FanEntityFeature.OSCILLATE,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Desk fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Bedroom fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Attic fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -332,6 +368,50 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (3 speeds)",
config: {
type: "tile",
entity: "fan.three_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (4 speeds)",
config: {
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
{
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
@@ -418,7 +498,7 @@ const CONFIGS = [
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
] satisfies DemoCardConfig<TileCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+55
View File
@@ -21,6 +21,61 @@ const ENTITIES = [
FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.one_speed_fan",
state: "on",
attributes: {
friendly_name: "One speed fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.two_speed_fan",
state: "on",
attributes: {
friendly_name: "Two speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 50,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Three speed fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Four speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Five speed fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
@customElement("demo-more-info-fan")
@@ -11,6 +11,7 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
const ALL_FEATURES =
LawnMowerEntityFeature.START_MOWING +
LawnMowerEntityFeature.PAUSE +
LawnMowerEntityFeature.STOP +
LawnMowerEntityFeature.DOCK;
const ENTITIES = [
@@ -49,6 +50,14 @@ const ENTITIES = [
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.idle",
state: "idle",
attributes: {
friendly_name: "Idle",
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.error",
state: "error",
@@ -1,8 +1,9 @@
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
@@ -34,9 +35,6 @@ const SCHEDULE_FETCH_OBSERVER_LOGS = 5;
@customElement("landing-page-logs")
class LandingPageLogs extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@query(".logs") private _logElement?: HTMLElement;
@@ -44,6 +42,10 @@ class LandingPageLogs extends LitElement {
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
@state() private _show = false;
@state() private _scrolledToBottomController =
@@ -63,12 +65,12 @@ class LandingPageLogs extends LitElement {
return html`
<div class="actions">
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
${this.localize(this._show ? "hide_details" : "show_details")}
${this._localize(this._show ? "hide_details" : "show_details")}
</ha-button>
${
this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.label=${this._localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
@@ -80,14 +82,14 @@ class LandingPageLogs extends LitElement {
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
.title=${this._localize("logs.fetch_error")}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
${this.localize("logs.retry")}
${this._localize("logs.retry")}
</ha-button>
</ha-alert>
`
@@ -115,7 +117,7 @@ class LandingPageLogs extends LitElement {
@click=${this._scrollToBottom}
>
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
${this.localize("logs.scroll_down_button")}
${this._localize("logs.scroll_down_button")}
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
</ha-button>
`;
@@ -1,35 +1,37 @@
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
LocalizeFunc,
} from "../../../src/common/translations/localize";
import "../../../src/components/ha-button";
import "../../../src/components/ha-alert";
import "../../../src/components/ha-button";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import {
ALTERNATIVE_DNS_SERVERS,
setSupervisorNetworkDns,
type NetworkInfo,
} from "../data/supervisor";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { fireEvent } from "../../../src/common/dom/fire_event";
@customElement("landing-page-network")
class LandingPageNetwork extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@property({ attribute: false }) public networkInfo?: NetworkInfo;
@property({ type: Boolean }) public error = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
protected render() {
if (this.error) {
return html`
<ha-alert alert-type="error">
<p>${this.localize("network_issue.error_get_network_info")}</p>
<p>${this._localize("network_issue.error_get_network_info")}</p>
</ha-alert>
`;
}
@@ -47,19 +49,21 @@ class LandingPageNetwork extends LitElement {
return html`
<ha-alert
alert-type="warning"
.title=${this.localize("network_issue.title")}
.title=${this._localize("network_issue.title")}
>
<p>
${this.localize("network_issue.description", {
${this._localize("network_issue.description", {
dns: dnsPrimaryInterfaceNameservers || "?",
})}
</p>
<p>${this.localize("network_issue.resolve_different")}</p>
<p>${this._localize("network_issue.resolve_different")}</p>
${
!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
<b
>${this._localize("network_issue.no_primary_interface")}
</b>
</p>
`
: nothing
@@ -72,7 +76,7 @@ class LandingPageNetwork extends LitElement {
.index=${key}
.disabled=${!dnsPrimaryInterfaceNameservers}
@click=${this._setDns}
>${this.localize(translationKey)}</ha-button
>${this._localize(translationKey)}</ha-button
>`
)}
</div>
@@ -118,12 +122,12 @@ class LandingPageNetwork extends LitElement {
// eslint-disable-next-line no-console
console.error(err);
showAlertDialog(this, {
title: this.localize("network_issue.failed"),
title: this._localize("network_issue.failed"),
warning: true,
text: `${this.localize(
text: `${this._localize(
"network_issue.set_dns_failed"
)}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this.localize("network_issue.close"),
)}${err?.message ? ` ${this._localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this._localize("network_issue.close"),
});
}
}
+2 -4
View File
@@ -9,6 +9,7 @@ import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { provideLiteI18nMixin } from "../../src/mixins/provide-lite-i18n-mixin";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
@@ -28,7 +29,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
@property({ attribute: false }) public translationFragment = "landing-page";
@state() private _supervisorError = false;
@@ -82,7 +83,6 @@ class HaLandingPage extends LandingPageBaseElement {
networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
@@ -103,13 +103,11 @@ class HaLandingPage extends LandingPageBaseElement {
: nothing
}
<landing-page-logs
.localize=${this.localize}
@landing-page-error=${this._showError}
></landing-page-logs>
</div>
</ha-card>
<onboarding-welcome-links
.localize=${this.localize}
.mobileApp=${this._mobileApp}
></onboarding-welcome-links>
<div class="footer">
+24 -26
View File
@@ -39,7 +39,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.0",
"@babel/runtime": "8.0.5",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.0",
@@ -52,22 +52,22 @@
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@formatjs/intl-datetimeformat": "7.6.2",
"@formatjs/intl-displaynames": "7.3.14",
"@formatjs/intl-durationformat": "0.11.0",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.14",
"@formatjs/intl-locale": "5.3.11",
"@formatjs/intl-numberformat": "9.4.1",
"@formatjs/intl-pluralrules": "6.3.14",
"@formatjs/intl-relativetimeformat": "12.3.14",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@home-assistant/webawesome": "3.7.0-ha.1",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -84,7 +84,6 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
@@ -104,20 +103,20 @@
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.2",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"intl-messageformat": "11.2.15",
"js-yaml": "5.4.1",
"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",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.12",
"memoize-one": "6.0.0",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -140,10 +139,10 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.1",
"@babel/core": "8.0.5",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@babel/preset-env": "8.0.5",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
@@ -153,8 +152,8 @@
"@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.2.2",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "2.2.3",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -163,7 +162,6 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
@@ -197,10 +195,10 @@
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"jszip": "3.10.1",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.5.0",
"lint-staged": "17.5.1",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -215,8 +213,8 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"typescript-eslint": "8.70.0",
"vite": "8.3.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.0",
"webpack-stats-plugin": "1.1.3",
@@ -234,6 +232,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.20.0"
"node": "24.21.0"
}
}
+20 -18
View File
@@ -23,6 +23,7 @@ import type {
} from "../data/data_entry_flow";
import "./ha-auth-form";
import type { HaAuthForm } from "./ha-auth-form";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
type State = "loading" | "error" | "step";
@@ -36,12 +37,14 @@ export class HaAuthFlow extends LitElement {
@property({ attribute: false }) public oauth2State?: string;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ attribute: false }) public initStoreToken = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _storeToken = false;
@state() private _state: State = "loading";
@@ -184,8 +187,8 @@ export class HaAuthFlow extends LitElement {
>
${
this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")
? this._localize("ui.panel.page-authorize.form.next")
: this._localize("ui.panel.page-authorize.form.start_over")
}
</ha-button>
</div>
@@ -193,20 +196,20 @@ export class HaAuthFlow extends LitElement {
case "error":
return html`
<ha-alert alert-type="error">
${this.localize("ui.panel.page-authorize.form.error", {
${this._localize("ui.panel.page-authorize.form.error", {
error: this._errorMessage,
})}
</ha-alert>
<div class="action">
<ha-button @click=${this._startOver}>
${this.localize("ui.panel.page-authorize.form.start_over")}
${this._localize("ui.panel.page-authorize.form.start_over")}
</ha-button>
</div>
`;
case "loading":
return html`
<ha-alert alert-type="info">
${this.localize("ui.panel.page-authorize.form.working")}
${this._localize("ui.panel.page-authorize.form.working")}
</ha-alert>
`;
default:
@@ -218,8 +221,8 @@ export class HaAuthFlow extends LitElement {
switch (step.type) {
case "abort":
return html`
${this.localize("ui.panel.page-authorize.abort_intro")}:
${this.localize(
${this._localize("ui.panel.page-authorize.abort_intro")}:
${this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}`
)}
`;
@@ -228,15 +231,14 @@ export class HaAuthFlow extends LitElement {
<h1>
${
!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")
? this._localize("ui.panel.page-authorize.welcome_home")
: this._localize("ui.panel.page-authorize.just_checking")
}
</h1>
${this._computeStepDescription(step)}
${keyed(
step.step_id,
html`<ha-auth-form
.localize=${this.localize}
.data=${this._stepData!}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
@@ -256,7 +258,7 @@ export class HaAuthFlow extends LitElement {
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
${this._localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""
@@ -266,7 +268,7 @@ export class HaAuthFlow extends LitElement {
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
>${this._localize("ui.panel.page-authorize.forgot_password")}</a
>
</div>
`;
@@ -336,13 +338,13 @@ export class HaAuthFlow extends LitElement {
private _computeStepDescription(step: DataEntryFlowStepForm) {
const resourceKey =
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const;
return this.localize(resourceKey, step.description_placeholders);
return this._localize(resourceKey, step.description_placeholders);
}
private _computeLabelCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.data.${schema.name}`
);
}
@@ -350,13 +352,13 @@ export class HaAuthFlow extends LitElement {
private _computeErrorCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.error.${error}`
);
}
private _unknownError() {
return this.localize("ui.panel.page-authorize.form.unknown_error");
return this._localize("ui.panel.page-authorize.form.unknown_error");
}
private _startOver() {
+6 -3
View File
@@ -1,6 +1,7 @@
/* eslint-disable lit/prefer-static-styles */
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaForm } from "../components/ha-form/ha-form";
import "./ha-auth-form-string";
@@ -9,11 +10,13 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
@customElement("ha-auth-form")
export class HaAuthForm extends HaForm {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected getFormProperties(): Record<string, any> {
return {
localize: this.localize,
localize: this._localize,
localizeBaseKey,
};
}
+4 -3
View File
@@ -12,6 +12,7 @@ import "../components/ha-svg-icon";
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
import { fetchAuthProviders } from "../data/auth";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { provideLiteI18nMixin } from "../mixins/provide-lite-i18n-mixin";
import { registerServiceWorker } from "../util/register-service-worker";
import "./ha-auth-flow";
@@ -23,7 +24,9 @@ const appNames = {
};
@customElement("ha-authorize")
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
export class HaAuthorize extends provideLiteI18nMixin(
litLocalizeLiteMixin(LitElement)
) {
@property({ attribute: false }) public clientId?: string;
@property({ attribute: false }) public redirectUri?: string;
@@ -183,14 +186,12 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${
inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
+6 -3
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
@@ -20,13 +21,15 @@ declare global {
export class HaPickAuthProvider extends LitElement {
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
return html`
<h3>
<span
>${this.localize("ui.panel.page-authorize.pick_auth_provider")}</span
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
+19 -21
View File
@@ -9,36 +9,34 @@ export const createDurationData = (
}
if (typeof duration !== "object") {
if (typeof duration === "string" || isNaN(duration)) {
const parts = duration?.toString().split(":") || [];
const durationString = duration.toString().trim();
// A leading "-" negates the whole period, matching
// `cv.time_period_str` in core.
const negative = durationString[0] === "-";
const parts = durationString
.split(":")
.map((part) =>
negative && part ? -Math.abs(Number(part)) : Number(part)
);
if (parts.length === 1) {
return { seconds: Number(parts[0]) };
return { seconds: parts[0] };
}
if (parts.length > 3) {
return undefined;
}
const seconds = Number(parts[2]) || 0;
const seconds_whole = Math.floor(seconds);
const seconds = parts[2] || 0;
const secondsWhole = Math.trunc(seconds);
return {
hours: Number(parts[0]) || 0,
minutes: Number(parts[1]) || 0,
seconds: seconds_whole,
milliseconds: Math.floor(
Number((seconds - seconds_whole).toFixed(4)) * 1000
hours: parts[0] || 0,
minutes: parts[1] || 0,
seconds: secondsWhole,
milliseconds: Math.trunc(
Number((seconds - secondsWhole).toFixed(4)) * 1000
),
};
}
return { seconds: duration };
}
if (!("days" in duration)) {
return duration;
}
const { days, minutes, seconds, milliseconds } = duration;
let hours = duration.hours || 0;
hours = (hours || 0) + (days || 0) * 24;
return {
hours,
minutes,
seconds,
milliseconds,
};
return duration;
};
+6
View File
@@ -1 +1,7 @@
export const stopPropagation = (ev) => ev.stopPropagation();
export const stopKeydownEnterSpacePropagation = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
ev.stopPropagation();
}
};
+131 -33
View File
@@ -1,33 +1,70 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import {
computeEntityEntryName,
computeEntityName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
import {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
export type {
EntityNameItem,
EntityNameOptions,
EntityNameType,
} from "./entity_name_config";
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
}
| {
type: "text";
text: string;
};
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
const computeTextOnlyName = (
items: EntityNameItem[],
options?: EntityNameOptions
): string | undefined =>
items.every((n) => n.type === "text")
? items
.map((item) => item.text)
.join(options?.separator ?? DEFAULT_SEPARATOR)
: undefined;
export interface EntityNameOptions {
separator?: string;
}
/**
* Name formatter used before the registry-aware one is installed
* (see state/connection-mixin and state/state-display-mixin). It honours a
* configured string or text-only name and falls back to the friendly name for
* anything that needs entity context, so a configured name is never dropped
* while the real formatter is still loading.
*/
export const computeEntityNameDisplayWithoutContext = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
if (!name) {
return computeStateName(stateObj);
}
return (
computeTextOnlyName(ensureArray(name), options) ??
computeStateName(stateObj)
);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
@@ -52,8 +89,9 @@ export const computeEntityNameDisplay = (
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
if (items.every((n) => n.type === "text")) {
return items.map((item) => item.text).join(separator);
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
@@ -91,20 +129,53 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, area, floor } = getEntityContext(
stateObj,
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
entities,
devices,
areas,
floors
);
const names = name.map((item) => {
return name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities, devices);
return computeEntityEntryName(entry, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -115,8 +186,29 @@ export const computeEntityNameList = (
return "";
}
});
};
return names;
export const computeEntitySearchLabels = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const { device, parentDevice, area } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
return {
entityName: computeEntityName(stateObj, entities, devices) || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: (device && computeDeviceName(device)) || null,
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
areaName: (area && computeAreaName(area)) || null,
};
};
export interface EntityPickerDisplay {
@@ -136,14 +228,20 @@ export const computeEntityPickerDisplay = (
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
@@ -152,7 +250,7 @@ export const computeEntityPickerDisplay = (
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
parentDevice: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
@@ -31,6 +32,7 @@ export const getEntityContext = (
return {
entity: null,
device: null,
parentDevice: null,
area: null,
floor: null,
};
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const parentDevice = device?.parent_device_id
? devices[device.parent_device_id]
: undefined;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
return {
entity: entity,
device: device || null,
parentDevice: parentDevice || null,
area: area || null,
floor: floor || null,
};
+34
View File
@@ -0,0 +1,34 @@
/**
* Shape of a composed entity name, kept free of runtime dependencies so that
* modules in the core entry chunk can name an entity without pulling in the
* registry-aware formatters from `compute_entity_name_display`.
*/
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
+2 -1
View File
@@ -29,7 +29,7 @@ export const FIXED_DOMAIN_STATES = {
fan: ["on", "off"],
humidifier: ["on", "off"],
input_boolean: ["on", "off"],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
lawn_mower: ["error", "paused", "mowing", "returning", "docked", "idle"],
light: ["on", "off"],
lock: [
"jammed",
@@ -95,6 +95,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+1 -1
View File
@@ -36,7 +36,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
case "person":
return compareState !== "not_home";
case "lawn_mower":
return !["docked", "paused"].includes(compareState);
return !["docked", "paused", "idle"].includes(compareState);
case "lock":
return compareState !== "locked";
case "media_player":
+11 -9
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
const VECTOR_STYLES = {
export const VECTOR_STYLES = {
light: "/static/map/light.json",
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";
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
export const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
const loadStyle = async (url: string): Promise<StyleSpecification> => {
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,9 +303,11 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
if (!rasterOnly && supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
+59
View File
@@ -0,0 +1,59 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
/** Advertised slider maximum; a larger radius raises it (see the engine) */
export const RADIUS_ARIA_MAX = 100000;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -0,0 +1,353 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
paddingBottomRight: [
options?.padding?.right ?? 0,
options?.padding?.bottom ?? 0,
],
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order, so an entity has the
* same color on every map. The registry comes from the fullEntitiesContext;
* entities without an entry (e.g. YAML zones) get a color derived from their
* id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
// One index per registry update, shared by every map on the page. Zones,
// persons and trackers share one sequence, so a person never has the color
// of the zone it is in.
const creationIndex = memoizeOne(
(entries: EntityRegistryEntry[]): Record<string, number> => {
const index: Record<string, number> = {};
entries
.filter(
(entry) =>
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
// The home zone has a fixed color and does not take a palette slot
entry.entity_id !== HOME_ZONE_ENTITY_ID
)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
return index;
}
);
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
computedStyles
);
/** The color a new zone will take once created, from the next creation-order slot */
export const nextZoneColor = (
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return getColorByIndex(
Object.keys(creationIndex(entries)).length,
computedStyles
);
};
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, entries, computedStyles);
};
+311
View File
@@ -0,0 +1,311 @@
/**
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
export interface MapFitPadding {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input; defaults to true */
interactive?: boolean;
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
focusable?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
readonly clusterData?: unknown;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
/**
* The point a distance away from center along a bearing (degrees clockwise
* from north), on the great circle. Longitude is left unwrapped so a ring of
* points stays continuous across the antimeridian.
*/
export const destinationPoint = (
center: MapLatLng,
distanceInMeters: number,
bearingDegrees: number
): MapLatLng => {
const angular = distanceInMeters / EARTH_RADIUS;
const lat = toRadians(center[0]);
const bearing = toRadians(bearingDegrees);
const destLat = Math.asin(
Math.sin(lat) * Math.cos(angular) +
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
);
const dLng = Math.atan2(
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
);
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
};
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const dLat = toRadians(b[0] - a[0]);
const dLng = toRadians(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(a[0])) *
Math.cos(toRadians(b[0])) *
Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
/**
* Bounding box corners of a circle, for fitting a radius into view. A circle
* that reaches a pole spans every longitude.
*/
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const angular = radiusMeters / EARTH_RADIUS;
const latMin = Math.max(-90, center[0] - toDegrees(angular));
const latMax = Math.min(90, center[0] + toDegrees(angular));
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
return [
[latMin, -180],
[latMax, 180],
];
}
const dLng = toDegrees(Math.asin(sinRatio));
return [
[latMin, center[1] - dLng],
[latMax, center[1] + dLng],
];
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Marker elements may be focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*
* What the engine sets is recorded on the element, so a rebuilt marker on a
* reused element gets fresh attributes while the caller's own are left alone.
*/
const OWNED = "haMapA11y";
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
focusable: boolean
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (
title &&
!element.hasAttribute("aria-label") &&
!element.hasAttribute("aria-labelledby")
) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
if (!element.hasAttribute("role")) {
if (focusable) {
element.setAttribute("role", "button");
owned.push("role");
} else if (title) {
element.setAttribute("role", "img");
owned.push("role");
} else {
element.setAttribute("aria-hidden", "true");
owned.push("aria-hidden");
}
}
if (focusable && !element.hasAttribute("tabindex")) {
element.tabIndex = 0;
owned.push("tabindex");
}
element.dataset[OWNED] = owned.join(" ");
};
/** Removes what setMarkerAccessibility set on the element */
export const clearMarkerAccessibility = (element: HTMLElement): void => {
const owned = element.dataset[OWNED];
if (owned === undefined) {
return;
}
owned
.split(" ")
.filter(Boolean)
.forEach((name) => element.removeAttribute(name));
delete element.dataset[OWNED];
};
+67
View File
@@ -0,0 +1,67 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
+1 -3
View File
@@ -31,11 +31,9 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
) => string;
@@ -0,0 +1,48 @@
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../data/translation";
import { translationMetadata } from "../../resources/translations-metadata";
import type { HomeAssistantInternationalization } from "../../types";
import type { LocalizeFunc } from "./localize";
/**
* Builds an internationalization context value for the "lite" localize mode
* used before a `hass` object exists. Locale falls back to browser defaults and
* the lazy loaders are stubs that warn when called.
*/
export const buildLiteInternationalization = (
language: string,
localize: LocalizeFunc
): HomeAssistantInternationalization => ({
language,
selectedLanguage: language,
locale: {
language,
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
localize,
translationMetadata,
loadBackendTranslation: async (category, integrations, configFlow) => {
// eslint-disable-next-line no-console
console.warn(
"loadBackendTranslation called in lite i18n mode; backend strings are not available",
{ category, integrations, configFlow }
);
return localize;
},
loadFragmentTranslation: async (fragment) => {
// eslint-disable-next-line no-console
console.warn(
`loadFragmentTranslation("${fragment}") called in lite i18n mode; fragment not loaded`
);
return undefined;
},
});
+5
View File
@@ -0,0 +1,5 @@
export const GITHUB_CORE_ISSUES_URL =
"https://github.com/home-assistant/core/issues";
export const GITHUB_FRONTEND_ISSUES_URL =
"https://github.com/home-assistant/frontend/issues";
@@ -0,0 +1,155 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
}
:host([variant="neutral"]) {
--background-color: var(--ha-color-fill-neutral-normal-resting);
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
--text-color: var(--ha-color-on-neutral-normal);
--background-color: var(--ha-color-fill-neutral-loud-resting);
--background-color-hover: var(--ha-color-fill-neutral-loud-hover);
--text-color: var(--ha-color-on-neutral-loud);
}
:host([variant="success"]) {
+11 -5
View File
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
border: 1px solid var(--ha-color-border-neutral-normal);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1);
margin-top: 10px;
@@ -172,7 +172,6 @@ export class HaAutomationRow extends LitElement {
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
}
:host([collapsed]) .expand-button {
@@ -185,9 +184,16 @@ export class HaAutomationRow extends LitElement {
outline-offset: -2px;
outline-width: 2px;
}
:host([disabled]) .row {
border-top-right-radius: var(--ha-border-radius-square);
border-top-left-radius: var(--ha-border-radius-square);
:host([disabled]) .row,
:host([disabled]) ::slotted([slot="leading-icon"]) {
color: var(--ha-color-text-disabled);
}
:host([disabled]) .leading-icon-wrapper {
opacity: 0.6;
}
:host([disabled]) ::slotted([slot="header"]) {
opacity: 0.6;
text-decoration: line-through;
}
.header {
position: relative;
+1 -1
View File
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
].flat();
// Always rounds down, so no chart ends up with fewer frames than it asked for.
function snapFrameSize(step: number): number {
export function snapFrameSize(step: number): number {
if (step >= DAY) {
return Math.floor(step / DAY) * DAY;
}
+196 -37
View File
@@ -34,6 +34,7 @@ import type {
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
@@ -57,7 +58,17 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const DEFAULT_CHART_WIDTH = 500;
export const DEFAULT_CHART_WIDTH = 500;
// Slack so a chart is up to date before a scroll can reach it. A phone screen
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
] as const;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -106,7 +117,7 @@ export type CustomLegendOption = ECOption["legend"] & {
};
@customElement("ha-chart-base")
export class HaChartBase extends LitElement {
export class HaChartBase extends MobileAwareMixin(LitElement) {
public chart?: EChartsType;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -183,20 +194,38 @@ export class HaChartBase extends LitElement {
private _layoutTransitionActive = false;
// Both start visible so a chart on screen renders on its first update rather
// than waiting a frame for the observers. A chart that starts off screen is
// therefore still built once; only its later updates are gated.
private _intersecting = true;
private _hasSize = true;
// Reported by the ResizeObserver, so rendering and downsampling need not
// measure layout themselves once it has fired.
private _contentWidth?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
callback: (entries) => {
// The controller also fires once with no entries when it starts observing.
const contentRect = entries[entries.length - 1]?.contentRect;
if (contentRect) {
this._contentWidth = contentRect.width;
this._hasSize = contentRect.width > 0 && contentRect.height > 0;
}
if (this.chart) {
if (this._suspendResize) {
this._shouldResizeChart = true;
return;
}
if (!this.chart.getZr().animation.isFinished()) {
} else if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
if (!this._suspendResize) {
this._applyDeferredWork();
}
},
});
@@ -210,10 +239,24 @@ export class HaChartBase extends LitElement {
private _pendingSetup = false;
private _pendingUpdate?: Set<PropertyKey>;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
this._intersecting = false;
this._hasSize = false;
this._contentWidth = undefined;
while (this._listeners.length) {
this._listeners.pop()!();
}
@@ -227,14 +270,31 @@ export class HaChartBase extends LitElement {
super.connectedCallback();
if (this.hasUpdated) {
this._pendingSetup = true;
afterNextRender(() => {
if (this.isConnected && this._pendingSetup) {
this._pendingSetup = false;
this._setupChart();
}
});
afterNextRender(() => this._applyDeferredWork());
}
const handleVisibilityChange = () => this._applyDeferredWork();
document.addEventListener("visibilitychange", handleVisibilityChange);
this._listeners.push(() =>
document.removeEventListener("visibilitychange", handleVisibilityChange)
);
const intersectionObserver = new IntersectionObserver(
(entries) => {
this._intersecting = entries[entries.length - 1].isIntersecting;
if (!this._suspendResize) {
this._applyDeferredWork();
}
},
{
rootMargin: this._isMobileSize
? VISIBILITY_ROOT_MARGIN_NARROW
: VISIBILITY_ROOT_MARGIN,
}
);
intersectionObserver.observe(this);
this._listeners.push(() => intersectionObserver.disconnect());
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
@@ -300,6 +360,7 @@ export class HaChartBase extends LitElement {
this._suspendResize = this._layoutTransitionActive;
if (!this._suspendResize) {
this._resizeChartIfNeeded();
this._applyDeferredWork();
}
};
window.addEventListener("hass-layout-transition", handleLayoutTransition);
@@ -312,24 +373,105 @@ export class HaChartBase extends LitElement {
}
protected firstUpdated() {
if (this.isConnected) {
this._setupChart();
if (!this.isConnected) {
return;
}
// The only measurement taken here: neither observer has reported yet, and a
// chart first rendered inside a hidden container has to defer its setup
// rather than build against a guessed width.
this._hasSize = this.clientWidth > 0 && this.clientHeight > 0;
if (this._isVisible()) {
this._setupChart();
} else {
this._pendingSetup = true;
}
}
private _isVisible() {
return (
document.visibilityState !== "hidden" &&
this._hasSize &&
this._intersecting
);
}
private _deferUpdate(changedProps: PropertyValues) {
for (const prop of DEFERRED_PROPS) {
if (!changedProps.has(prop)) {
continue;
}
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
private async _applyDeferredWork() {
if (!this._pendingSetup && !this._pendingUpdate) {
return;
}
if (!this.isConnected || !this._isVisible()) {
return;
}
if (this._pendingSetup) {
await this._setupChart();
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
if (!this.chart) {
return;
}
if (changedProps.has("_themes") && this.hasUpdated) {
this._setupChart();
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
}
const invisible = !this._isVisible();
if (themeChanged) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("options")) {
// Separate 'if' from below since this must updated before _getSeries()
// Separate 'if' from below since this must be updated before _getSeries().
// It stays out of _applyChartUpdate so a replay cannot request another
// update and turn one catch-up render into two.
this._updateHiddenStatsFromOptions(this.options);
}
if (invisible) {
if (!this._pendingSetup) {
this._deferUpdate(changedProps);
}
return;
}
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
@@ -347,12 +489,7 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
if (
this._compareCustomLegendOptions(
changedProps.get("options"),
this.options
)
) {
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
@@ -463,10 +600,7 @@ export class HaChartBase extends LitElement {
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const overflowLimit = isMobile
const overflowLimit = this._isMobileSize
? LEGEND_OVERFLOW_LIMIT_MOBILE
: LEGEND_OVERFLOW_LIMIT;
return html`<div
@@ -578,7 +712,11 @@ export class HaChartBase extends LitElement {
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading || !this.chart) {
if (this._sonification || this._sonificationLoading) {
return;
}
await this._applyDeferredWork();
if (!this.chart) {
return;
}
this._sonificationLoading = true;
@@ -635,14 +773,22 @@ export class HaChartBase extends LitElement {
);
private async _setupChart() {
if (this._loading) return;
if (this._loading) {
this._pendingSetup = true;
return;
}
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
const echarts = (await import("../../resources/echarts/echarts")).default;
@@ -780,8 +926,14 @@ export class HaChartBase extends LitElement {
series: this._getSeries(),
});
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
this._pendingZoom = undefined;
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
}
} finally {
this._loading = false;
this._applyDeferredWork();
}
}
@@ -927,9 +1079,7 @@ export class HaChartBase extends LitElement {
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const isMobile = this._isMobileSize;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
@@ -1166,8 +1316,8 @@ export class HaChartBase extends LitElement {
data: downSampleLineData(
data as LineSeriesOption["data"],
// 0 while inside a hidden container, e.g. a section with a visibility condition
(this.clientWidth || DEFAULT_CHART_WIDTH) *
window.devicePixelRatio,
((this._contentWidth ?? this.clientWidth) ||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
minX,
maxX
),
@@ -1180,7 +1330,7 @@ export class HaChartBase extends LitElement {
}
private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 200);
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
}
private _setChartOptions(options: ECOption) {
@@ -1247,7 +1397,16 @@ export class HaChartBase extends LitElement {
};
public zoom(start: number, end: number, silent = false) {
this.chart?.dispatchAction({
if (!this.chart || this._pendingSetup) {
// Sibling charts sync their zoom imperatively, so a range that arrives
// before a deferred setup or rebuild has to be replayed rather than
// dropped. A reset to the full range is what a fresh chart is built with,
// so it just clears.
this._pendingZoom =
start === 0 && end === 100 ? undefined : [start, end, silent];
return;
}
this.chart.dispatchAction({
type: "dataZoom",
start,
end,
+55 -17
View File
@@ -12,7 +12,6 @@ import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
export interface Node {
@@ -43,6 +42,9 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@@ -55,6 +57,9 @@ export class HaSankeyChart extends LitElement {
@property({ type: Boolean }) public vertical = false;
@property({ type: Boolean, attribute: "show-values" }) public showValues =
false;
@property({ attribute: false }) public valueFormatter?: (
value: number
) => string;
@@ -84,7 +89,11 @@ export class HaSankeyChart extends LitElement {
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.data=${this._createData(
this.data,
this._sizeController.value?.width,
this.showValues
)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@@ -142,7 +151,11 @@ export class HaSankeyChart extends LitElement {
}
};
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
private _computeData = (
data: SankeyChartData,
width = 0,
showValues = false
) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>();
@@ -183,6 +196,10 @@ export class HaSankeyChart extends LitElement {
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
const verticalBottom = showValues
? LABEL_DISTANCE + FONT_SIZE * 3 + OVERFLOW_MARGIN
: 25;
return {
id: "sankey",
@@ -209,29 +226,48 @@ export class HaSankeyChart extends LitElement {
layoutIterations: 0,
animationDuration: 500,
label: {
formatter: (params) =>
data.nodes.find((node) => node.id === (params.data as Node).id)
?.label ?? (params.data as Node).id,
formatter: (params) => {
const nodeData = params.data as { id: string; value: number };
const node = data.nodes.find((n) => n.id === nodeData.id);
const label = node?.label ?? nodeData.id;
if (!showValues || !nodeData.id) return label;
const formatted = this.valueFormatter
? this.valueFormatter(nodeData.value).trim()
: String(nodeData.value);
// LRM keeps numeric values LTR on the canvas without creating wrap points.
return `${label}\n\u200E${formatted}`;
},
position: this.vertical ? "bottom" : "right",
distance: LABEL_DISTANCE,
minMargin: 5,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
},
labelLayout: (params) => {
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
.split(" ")
.reduce(
(longest, current) =>
longest.length > current.length ? longest : current,
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
.replace(BIDI_MARKS, "")
.split(/[ \n]+/)
.reduce((longest, current) => {
if (!current) {
return longest;
}
if (!longest) {
return current;
}
return measureTextWidth(current, FONT_SIZE) >
measureTextWidth(longest, FONT_SIZE)
? current
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
@@ -254,14 +290,16 @@ export class HaSankeyChart extends LitElement {
};
},
top: this.vertical ? 0 : OVERFLOW_MARGIN,
bottom: this.vertical ? 25 : OVERFLOW_MARGIN,
bottom: this.vertical ? verticalBottom : OVERFLOW_MARGIN,
left: this.vertical ? OVERFLOW_MARGIN : 0,
right: this.vertical ? OVERFLOW_MARGIN : labelSpace + LABEL_DISTANCE,
emphasis: {
focus: "adjacency",
},
} as SankeySeriesOption;
});
};
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
@@ -0,0 +1,258 @@
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
} from "echarts/types/dist/shared";
import type { HassEntities } from "home-assistant-js-websocket";
import { hex2rgb } from "../../common/color/convert-color";
import { luminosity } from "../../common/color/rgb";
import type { TimelineEntity } from "../../data/history";
import { snapFrameSize } from "./down-sample";
import { computeTimelineColor } from "./timeline-color";
export interface StateHistoryChartTimelineDataParams {
states: HassEntities;
data: TimelineEntity[];
startTime: Date;
endTime: Date;
names?: Record<string, string>;
showNames: boolean;
computedStyles: CSSStyleDeclaration;
renderItem: CustomSeriesRenderItem;
/** Chart width in device pixels; bounds how many rectangles are emitted. */
chartWidth: number;
}
export interface TimelineSegment {
state: string;
locState: string | null;
start: number;
end: number;
}
/** Resolves each frame of a run to the state covering most of that frame. */
function collapseRun(
segments: TimelineSegment[],
from: number,
to: number,
frameMs: number,
push: (segment: TimelineSegment) => void
) {
const runEnd = segments[to - 1].end;
const frameStates = new Map<
string,
{ duration: number; locState: string | null }
>();
let frameStart = segments[from].start;
let index = from;
while (frameStart < runEnd) {
const boundary = (Math.floor(frameStart / frameMs) + 1) * frameMs;
// a frame size that rounds back onto frameStart would never advance
const next = boundary > frameStart ? boundary : frameStart + frameMs;
const frameEnd = next < runEnd ? next : runEnd;
frameStates.clear();
let bestState: string | null = null;
let bestLocState: string | null = null;
let bestDuration = 0;
// Segments are narrower than a frame, so each is visited at most twice:
// index stops at the one spilling into the next frame.
let cursor = index;
while (cursor < to && segments[cursor].start < frameEnd) {
const segment = segments[cursor];
cursor++;
const overlapStart =
segment.start > frameStart ? segment.start : frameStart;
const overlapEnd = segment.end < frameEnd ? segment.end : frameEnd;
if (overlapEnd <= overlapStart) {
continue;
}
let entry = frameStates.get(segment.state);
if (entry) {
entry.duration += overlapEnd - overlapStart;
} else {
entry = {
duration: overlapEnd - overlapStart,
locState: segment.locState,
};
frameStates.set(segment.state, entry);
}
if (entry.duration > bestDuration) {
bestDuration = entry.duration;
bestState = segment.state;
bestLocState = entry.locState;
}
}
while (index < to && segments[index].end <= frameEnd) {
index++;
}
if (bestState !== null) {
push({
state: bestState,
locState: bestLocState,
start: frameStart,
end: frameEnd,
});
}
frameStart = frameEnd;
}
}
/**
* Bounds the rectangle count by the chart's pixel width. Segments at least one
* frame wide are kept as they are; narrower ones are resolved per frame, and
* neighbours resolving to the same state merge into one rectangle.
*/
export function downSampleTimelineSegments(
segments: TimelineSegment[],
frameMs: number
): TimelineSegment[] {
if (!(frameMs > 0)) {
return segments;
}
const result: TimelineSegment[] = [];
const push = (segment: TimelineSegment) => {
const last = result[result.length - 1];
if (last && last.state === segment.state && last.end === segment.start) {
last.end = segment.end;
return;
}
result.push(segment);
};
let index = 0;
while (index < segments.length) {
if (segments[index].end - segments[index].start >= frameMs) {
push({ ...segments[index] });
index++;
continue;
}
const from = index;
index++;
// A gap of its own frame or more stays a gap; a narrower one is invisible
// and is absorbed, so that a row of gap-separated slivers stays bounded.
while (
index < segments.length &&
segments[index].end - segments[index].start < frameMs &&
segments[index].start - segments[index - 1].end < frameMs
) {
index++;
}
collapseRun(segments, from, index, frameMs, push);
}
return result;
}
/**
* Transforms processed history (`TimelineEntity[]`) into ECharts custom series
* for `state-history-chart-timeline`. Pure data processing: all environment
* inputs (theme style, entity states, chart width, the render callback) are injected so
* the transform is deterministic and benchmarkable.
*/
export function generateStateHistoryChartTimelineData(
params: StateHistoryChartTimelineDataParams
): CustomSeriesOption[] {
const { states, computedStyles, startTime, endTime, renderItem } = params;
const stateHistory = params.data ?? [];
const startTimeMs = startTime.getTime();
const endTimeMs = endTime.getTime();
// Snapped, and placed on absolute time, so a chart following "now" keeps
// resolving the same frames instead of reshaping on every refresh.
const rawFrameMs = Math.ceil(
(endTimeMs - startTimeMs) / Math.floor(params.chartWidth)
);
const frameMs =
Number.isFinite(rawFrameMs) && rawFrameMs > 0
? snapFrameSize(rawFrameMs)
: 0;
const datasets: CustomSeriesOption[] = [];
const names = params.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTimeMs;
const entityDisplay: string = params.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const segments: TimelineSegment[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = entityState.last_changed;
if (!newState) {
newState = null;
}
if (timeStamp > endTimeMs) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
} else if (newState !== prevState) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: timeStamp,
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
}
});
if (prevState !== null) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: endTimeMs,
});
}
const stateObj = states[stateInfo.entity_id];
const dataRow = downSampleTimelineSegments(segments, frameMs).map(
(segment) => {
const color = computeTimelineColor(
segment.state,
computedStyles,
stateObj
);
return {
value: [
stateInfo.entity_id,
new Date(segment.start),
new Date(segment.end),
segment.locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
};
}
);
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem,
progressive: 0,
});
});
return datasets;
}
@@ -11,18 +11,18 @@ import millisecondsToDuration from "../../common/datetime/milliseconds_to_durati
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 { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { generateStateHistoryChartTimelineData } from "./state-history-chart-timeline-data";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +41,12 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render each row's name inside the plot (under its bar) instead of in a
// left-hand category-label column. Opt-in; used by the history panel and
// history-graph card.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -70,7 +76,13 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${
this.data.length *
(this.insideLabels && (this.chunked || this.showNames)
? ROW_HEIGHT_INSIDE_LABELS
: ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -185,6 +197,7 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
@@ -196,9 +209,11 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
@@ -227,31 +242,47 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
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,
});
axisLabel: insideLabels
? {
// Draw the name inside the plot, under each row's bar, matching
// the line charts whose legend sits under the plot. The taller
// rows keep a name clear of the next row's bar.
show: showNames,
inside: true,
margin: 0,
padding: [18, 0, 0, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "top",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
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,
},
},
grid: {
top: 10,
@@ -282,109 +313,20 @@ export class StateHistoryChartTimeline extends LitElement {
}
private _generateData() {
const computedStyles = getComputedStyle(this);
let stateHistory = this.data;
if (!stateHistory) {
stateHistory = [];
}
this._chartTime = new Date();
const startTime = this.startTime;
const endTime = this.endTime;
const datasets: CustomSeriesOption[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let newLastChanged: Date;
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
if (!newState) {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = new Date(entityState.last_changed);
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = newLastChanged;
}
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
progressive: 0,
});
this._chartData = generateStateHistoryChartTimelineData({
states: this.hass.states,
data: this.data,
startTime: this.startTime,
endTime: this.endTime,
names: this.names,
showNames: this.showNames,
computedStyles: getComputedStyle(this),
renderItem: this._renderItem,
// 0 while inside a hidden container, e.g. a section with a visibility condition
chartWidth:
(this.clientWidth || DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
});
this._chartData = datasets;
}
private _handleChartClick(
@@ -401,6 +343,9 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
+11 -1
View File
@@ -79,6 +79,11 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render timeline row names inside the plot (under each bar) instead of in a
// left-hand column. Opt-in; used by the history panel and history-graph card.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +232,7 @@ 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}
@@ -369,7 +375,7 @@ export class StateHistoryCharts extends LitElement {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
if (chartBase && chartBase.chart) {
if (chartBase) {
chartBase.zoom(0, 100);
}
});
@@ -444,6 +450,10 @@ export class StateHistoryCharts extends LitElement {
margin-top: 16px;
}
.entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.container,
lit-virtualizer {
height: 100%;
+47 -1
View File
@@ -53,6 +53,41 @@ export interface StatisticsChartData {
yAxisFractionDigits: number;
}
// ECharts stacks a time axis by data index. Merge the sorted point lists so
// every stacked line has the same x at every index, padding with null.
function alignStackedLines(datasets: LineSeriesOption[]) {
const sources = datasets.map((d) => d.data as [number, number | null][]);
const cursors = sources.map(() => 0);
const counts = sources.map(() => 0);
const aligned = sources.map((): [number, number | null][] => []);
while (cursors.some((cursor, i) => cursor < sources[i].length)) {
let x = Infinity;
for (let i = 0; i < sources.length; i++) {
const point = sources[i][cursors[i]];
if (point && point[0] < x) x = point[0];
}
let slots = 1;
for (let i = 0; i < sources.length; i++) {
let count = 0;
while (sources[i][cursors[i] + count]?.[0] === x) count++;
counts[i] = count;
if (count > slots) slots = count;
}
for (let i = 0; i < sources.length; i++) {
const count = counts[i];
for (let slot = 0; slot < slots; slot++) {
aligned[i].push(
count ? sources[i][cursors[i] + Math.min(slot, count - 1)] : [x, null]
);
}
cursors[i] += count;
}
}
datasets.forEach((d, i) => {
d.data = aligned[i];
});
}
/**
* Transforms raw statistics into ECharts series for `statistics-chart`.
* Pure data processing: all environment inputs (current time, theme style,
@@ -263,8 +298,10 @@ export function generateStatisticsChartData(
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// echarts stacks before it samples, so its lttb keeps stacks aligned
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
sampling:
band || (chartStacked && chartType === "line") ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
@@ -439,6 +476,15 @@ export function generateStatisticsChartData(
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "line" && chartStacked) {
const stacked = (totalDataSets as LineSeriesOption[]).filter(
(d) => d.data?.length
);
if (stacked.length > 1) {
alignStackedLines(stacked);
}
}
if (chartType === "bar") {
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
}
+40 -12
View File
@@ -125,7 +125,23 @@ export class StatisticsChart extends LitElement {
private _yAxisFractionDigits = 1;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
return (
changedProps.size > 1 ||
!changedProps.has("hass") ||
this._entityNamesChanged(changedProps)
);
}
// Series names are resolved once and cached in _chartData, so a hass update
// that only replaces the formatters has to regenerate them. The formatters are
// swapped as a set whenever the registries change, which is what renames a
// series.
private _entityNamesChanged(changedProps: PropertyValues): boolean {
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
return !!oldHass && oldHass.formatEntityName !== this.hass.formatEntityName;
}
public willUpdate(changedProps: PropertyValues) {
@@ -135,7 +151,8 @@ export class StatisticsChart extends LitElement {
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats") ||
changedProps.has("names")
changedProps.has("names") ||
this._entityNamesChanged(changedProps)
) {
this._generateData();
}
@@ -253,6 +270,8 @@ export class StatisticsChart extends LitElement {
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
// stacked lines are padded with null where a statistic has no data
if (!chartIsBar && param.value[1] === null) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
@@ -530,17 +549,26 @@ export class StatisticsChart extends LitElement {
this.unit = data.unit;
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
if (data.legendData.length !== this._legendData?.length) {
const legendData =
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
}))
: // if there is only one entity, let the base chart handle the legend
undefined;
if (
legendData?.length !== this._legendData?.length ||
legendData?.some(
(item, index) =>
item.id !== this._legendData?.[index]?.id ||
item.name !== this._legendData?.[index]?.name ||
item.noLabelClick !== this._legendData?.[index]?.noLabelClick
)
) {
// only update the legend if it has changed or it will trigger options update
this._legendData =
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
}))
: // if there is only one entity, let the base chart handle the legend
undefined;
this._legendData = legendData;
}
this._statisticIds = data.statisticIds;
}
+8
View File
@@ -35,6 +35,14 @@ export class HaFilterChip extends FilterChip {
`,
];
protected getContainerClasses() {
const classes = super.getContainerClasses();
if (this.noLeadingIcon) {
classes["has-icon"] = false;
}
return classes;
}
protected renderLeadingIcon() {
if (this.noLeadingIcon) {
// eslint-disable-next-line lit/prefer-nothing
@@ -1202,6 +1202,20 @@ export class HaDataTable extends LitElement {
overflow: auto;
}
:host([narrow]) .mdc-data-table {
width: calc(
100% + var(--safe-area-inset-left, 0px) +
var(--safe-area-inset-right, 0px)
);
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
overflow: visible;
}
:host([narrow]) .mdc-data-table__header-row {
overflow: visible;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.mdc-data-table__header-row::-webkit-scrollbar {
display: none;
@@ -1499,6 +1513,16 @@ export class HaDataTable extends LitElement {
overflow: overlay !important;
}
:host([narrow]) .mdc-data-table__row {
box-sizing: border-box;
padding-left: var(--safe-area-inset-left, 0px);
padding-right: var(--safe-area-inset-right, 0px);
}
:host([narrow]) .mdc-data-table__row:has(.group-header) {
background-color: var(--primary-background-color);
}
.mdc-data-table__table.auto-height .scroller {
overflow-y: hidden !important;
}
+12 -11
View File
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import {
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
@@ -135,9 +136,7 @@ export class HaEntityNamePicker extends LitElement {
${
this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
<ha-input-helper-text> ${this.helper} </ha-input-helper-text>
`
: nothing
}
@@ -170,6 +169,7 @@ export class HaEntityNamePicker extends LitElement {
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
@@ -387,6 +387,7 @@ export class HaEntityNamePicker extends LitElement {
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
@@ -399,8 +400,8 @@ export class HaEntityNamePicker extends LitElement {
const types = this._validTypes(entityId);
const items = (
["entity", "device", "area", "floor"] as const
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
@@ -464,7 +465,7 @@ export class HaEntityNamePicker extends LitElement {
const excludedValues = new Set(
this._items
.filter((item) => UNIQUE_TYPES.has(item.type))
.filter((item) => KNOWN_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
),
});
}
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
@@ -318,11 +329,7 @@ export class HaStateContentPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+27 -45
View File
@@ -11,9 +11,10 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { domainToName } from "../../data/integration";
import {
getStatisticLabel,
@@ -52,6 +53,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
@@ -232,11 +234,6 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
@@ -290,22 +287,18 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
const { primary, secondary } = computeEntityPickerDisplay(
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
id,
@@ -314,13 +307,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: searchLabels,
});
});
@@ -395,26 +387,18 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
@@ -423,14 +407,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: { ...searchLabels, statisticId },
};
}
+41 -13
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
@@ -20,13 +21,15 @@ declare global {
@customElement("ha-analytics")
export class HaAnalytics extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public analytics?: Analytics;
@property({ attribute: "translation_key_panel" }) public translationKeyPanel:
"page-onboarding" | "config" = "config";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
const loading = this.analytics === undefined;
const baseEnabled = !loading && this.analytics!.preferences.base;
@@ -34,12 +37,12 @@ export class HaAnalytics extends LitElement {
return html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.description`
)}</span
>
@@ -50,18 +53,22 @@ export class HaAnalytics extends LitElement {
.preference=${"base"}
.disabled=${loading}
name="base"
></ha-switch>
>
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
</ha-row-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
)}</span
>
@@ -72,7 +79,11 @@ export class HaAnalytics extends LitElement {
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
></ha-switch>
>
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
${
baseEnabled
? nothing
@@ -80,7 +91,7 @@ export class HaAnalytics extends LitElement {
.for="switch-${preference}"
placement="right"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`
@@ -90,12 +101,12 @@ export class HaAnalytics extends LitElement {
)}
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.description`
)}</span
>
@@ -106,7 +117,11 @@ export class HaAnalytics extends LitElement {
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
></ha-switch>
>
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
</ha-row-item>
`;
}
@@ -143,6 +158,19 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
/* The visible headline already names the row. Keep the switch's
slotted label available to assistive technology without repeating it. */
ha-switch::part(label) {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
+4 -19
View File
@@ -3,8 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeRTL } from "../common/util/compute_rtl";
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
@@ -183,11 +182,6 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
return;
@@ -197,20 +191,11 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
stateObj
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
entityItems.push({
type: "entity",
id: entityId,
+3 -2
View File
@@ -22,6 +22,7 @@ import {
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import type { ChatLogToolResult } from "../data/chat_log";
import {
configContext,
connectionContext,
@@ -58,7 +59,7 @@ interface AssistMessage {
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: any;
result?: ChatLogToolResult;
}
>;
error?: boolean;
@@ -834,7 +835,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.tool_result;
delta.result;
this.requestUpdate("_conversation");
}
}
+1 -3
View File
@@ -312,9 +312,7 @@ export class HaBaseTimeInput extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
+2 -2
View File
@@ -42,8 +42,8 @@ export class HaCard extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
line-height: var(--ha-line-height-condensed);
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-6);
display: block;
margin-block-start: 0;
margin-block-end: 0;
@@ -198,11 +198,7 @@ export class HaClockDateFormatPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+12
View File
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
});
}
if (parentDeviceName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.device-picker.parent_device"
),
value: parentDeviceName,
});
}
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
+55 -32
View File
@@ -103,69 +103,87 @@ export class HaDurationInput extends LitElement {
`;
}
// Fold units we do not render into the smallest unit we do, so a value the
// user cannot see is not silently dropped on the next edit.
private get _data(): HaDurationData | undefined {
if (!this.data) {
return this.data;
}
const data = { ...this.data };
if (!this.enableDay && data.days) {
data.hours = (data.hours || 0) + data.days * 24;
delete data.days;
}
if (!this.enableMillisecond && data.milliseconds) {
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
delete data.milliseconds;
}
return data;
}
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
(this._data?.days
? this._data.days < 0
: this._data?.hours
? this._data.hours < 0
: this._data?.minutes
? this._data.minutes < 0
: this._data?.seconds
? this._data.seconds < 0
: this._data?.milliseconds
? this._data.milliseconds < 0
: false)
);
}
private get _days() {
return this.data?.days
return this._data?.days
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
: this.required || this.data
? Math.abs(Number(this._data.days))
: Number(this._data.days)
: this.required || this._data
? 0
: NaN;
}
private get _hours() {
return this.data?.hours
return this._data?.hours
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
: this.required || this.data
? Math.abs(Number(this._data.hours))
: Number(this._data.hours)
: this.required || this._data
? 0
: NaN;
}
private get _minutes() {
return this.data?.minutes
return this._data?.minutes
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
: this.required || this.data
? Math.abs(Number(this._data.minutes))
: Number(this._data.minutes)
: this.required || this._data
? 0
: NaN;
}
private get _seconds() {
return this.data?.seconds
return this._data?.seconds
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
: this.required || this.data
? Math.abs(Number(this._data.seconds))
: Number(this._data.seconds)
: this.required || this._data
? 0
: NaN;
}
private get _milliseconds() {
return this.data?.milliseconds
return this._data?.milliseconds
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
: this.required || this.data
? Math.abs(Number(this._data.milliseconds))
: Number(this._data.milliseconds)
: this.required || this._data
? 0
: NaN;
}
@@ -236,8 +254,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
if (this._data) {
const value = { ...this._data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
@@ -254,6 +272,11 @@ export class HaDurationInput extends LitElement {
display: flex;
align-items: center;
}
/* Full width: bigger touch targets, no ragged right edge in a form. */
ha-base-time-input {
flex: 1;
--time-input-flex: 1;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
+15 -12
View File
@@ -27,9 +27,9 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -92,7 +92,6 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this._localize!;
return html`
${
this.uploading
@@ -102,10 +101,13 @@ export class HaFileUpload extends LitElement {
>${
this.uploadingLabel ||
(this.value
? localize("ui.components.file-upload.uploading_name", {
name: this._name,
})
: localize("ui.components.file-upload.uploading"))
? this._localize(
"ui.components.file-upload.uploading_name",
{
name: this._name,
}
)
: this._localize("ui.components.file-upload.uploading"))
}</span
>
${
@@ -147,12 +149,12 @@ export class HaFileUpload extends LitElement {
slot="start"
.path=${this.icon || mdiFileUpload}
></ha-svg-icon>
${this.label || localize("ui.components.file-upload.label")}
${this.label || this._localize("ui.components.file-upload.label")}
</ha-button>
<span class="secondary"
>${
this.secondary ||
localize("ui.components.file-upload.secondary")
this._localize("ui.components.file-upload.secondary")
}</span
>
<span class="supports">${this.supports}</span>`
@@ -166,7 +168,7 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.deleteLabel || localize("ui.common.delete")}
.label=${this.deleteLabel || this._localize("ui.common.delete")}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -185,7 +187,8 @@ export class HaFileUpload extends LitElement {
<ha-icon-button
@click=${this._clearValue}
.label=${
this.deleteLabel || localize("ui.common.delete")
this.deleteLabel ||
this._localize("ui.common.delete")
}
.path=${mdiDelete}
></ha-icon-button>
+11 -1
View File
@@ -60,6 +60,9 @@ export class HaFilterDevices extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@@ -227,7 +230,14 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
relatedPromises.push(
findRelated(
this._api,
"device",
deviceId,
this.includeDisabledEntities
)
);
}
}
const results = await Promise.all(relatedPromises);
+14 -2
View File
@@ -65,6 +65,9 @@ export class HaFilterFloorAreas extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@@ -295,7 +298,9 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
relatedPromises.push(
findRelated(this._api, "area", areaId, this.includeDisabledEntities)
);
}
}
}
@@ -303,7 +308,14 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
relatedPromises.push(
findRelated(
this._api,
"floor",
floorId,
this.includeDisabledEntities
)
);
}
}
}
@@ -22,7 +22,6 @@ import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
+1 -1
View File
@@ -91,7 +91,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
? html`<ha-input-helper-text
>${this.helper}</ha-input-helper-text
>`
: nothing
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../ha-check-list-item";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-dropdown";
+1
View File
@@ -57,6 +57,7 @@ export class HaFormString extends LitElement implements HaFormElement {
.name=${this.schema.name}
.autofocus=${!!this.schema.autofocus}
.autocomplete=${this.schema.autocomplete}
.inputId=${this.schema.name}
.validationMessage=${
this.schema.required
? this.localize?.("ui.common.error_required")
+2 -2
View File
@@ -329,7 +329,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
return html`<ha-input-helper-text>
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
@@ -360,7 +360,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._initialFieldValue = undefined;
}
if (
this._hassConfig?.auth.external &&
this._hassConfig?.auth?.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
+16
View File
@@ -0,0 +1,16 @@
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import { HaSvgIcon } from "./ha-svg-icon";
@customElement("ha-icon-arrow-next")
export class HaIconArrowNext extends HaSvgIcon {
@property() public override path =
mainWindow.document.dir === "rtl" ? mdiArrowLeft : mdiArrowRight;
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-arrow-next": HaIconArrowNext;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import { HaSvgIcon } from "./ha-svg-icon";
@customElement("ha-icon-arrow-prev")
export class HaIconArrowPrev extends HaSvgIcon {
@property() public override path =
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-arrow-prev": HaIconArrowPrev;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
@customElement("ha-icon-button-first")
export class HaIconButtonFirst extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl"
? mdiChevronDoubleRight
: mdiChevronDoubleLeft;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<ha-icon-button
.disabled=${this.disabled}
.label=${this.label || this._localize("ui.common.first") || "First"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-button-first": HaIconButtonFirst;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
@customElement("ha-icon-button-last")
export class HaIconButtonLast extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl"
? mdiChevronDoubleLeft
: mdiChevronDoubleRight;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<ha-icon-button
.disabled=${this.disabled}
.label=${this.label || this._localize("ui.common.last") || "Last"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-button-last": HaIconButtonLast;
}
}
+1 -6
View File
@@ -1,11 +1,9 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
@customElement("ha-input-helper-text")
class InputHelperText extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
protected render(): TemplateResult {
return html`<slot></slot>`;
}
@@ -25,9 +23,6 @@ class InputHelperText extends LitElement {
);
line-height: normal;
}
:host([disabled]) {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
`;
}
+1 -3
View File
@@ -50,9 +50,7 @@ class HaLabeledSlider extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>`
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
: nothing
}
`;
@@ -28,6 +28,11 @@ const LAWN_MOWER_ACTIONS: Partial<
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
idle: {
action: "start_mowing",
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
returning: {
action: "pause",
service: "pause",
+1 -3
View File
@@ -239,9 +239,7 @@ export class HaMediaItemPicker extends LitElement {
}
${
hideEntityPicker && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
-26
View File
@@ -1,26 +0,0 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-outlined-button")
export class HaOutlinedButton extends OutlinedButton {
static override styles = [
sharedStyles,
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-outline: var(--outline-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-button": HaOutlinedButton;
}
}
-36
View File
@@ -1,36 +0,0 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
@customElement("ha-outlined-field")
export class HaOutlinedField extends OutlinedField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
sharedStyles,
styles,
css`
.container::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--ha-outlined-field-container-color, transparent);
opacity: var(--ha-outlined-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-start-radius: var(--_container-shape-end-start);
border-end-end-radius: var(--_container-shape-end-end);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-field": HaOutlinedField;
}
}
+126 -17
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
@@ -11,39 +11,80 @@ import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* A persistent sheet never closes by dragging; it stops at its minimum height
* instead, so a host can keep a collapsed strip of content reachable.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels once
* it has opened and whenever a drag ends
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
* grab area reaches; shrink it when content sits right under the handle
*/
@customElement("ha-resizable-bottom-sheet")
export class HaResizableBottomSheet extends LitElement {
@query("dialog") private _dialog!: HTMLDialogElement;
/** Dragging down stops at the minimum height instead of closing the sheet */
@property({ type: Boolean }) public persistent = false;
/**
* The height in pixels the sheet cannot be dragged below, e.g. what a host
* measures for the strip it wants to keep visible. Without it the sheet
* stops at 20% of the viewport.
*/
@property({ type: Number, attribute: "min-height" })
public minHeight?: number;
/**
* The largest share of the viewport the sheet opens at, in percent. It
* opens at its content height up to this, and can be dragged to 90 after.
*/
@property({ type: Number, attribute: "open-max-viewport-height" })
public openMaxViewportHeight = 70;
/**
* Whether the sheet may open smaller than 55% of the viewport, down to its
* content height and the minimum it can be dragged to.
*/
@property({ type: Boolean, attribute: "open-at-content-height" })
public openAtContentHeight = false;
private _dragging = false;
private _dragStartY = 0;
private _initialSize = 0;
@state() private _dialogMaxViewpointHeight = 70;
private _opened = false;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogMaxViewpointHeight?: number;
@state() private _dialogMinViewpointHeight?: number;
@state() private _dialogViewportHeight?: number;
render() {
// Until it has opened, the sheet sizes to its content within the opening
// bounds; afterwards it keeps the height it settled on or was dragged to
const maxHeight =
this._dialogMaxViewpointHeight ?? this.openMaxViewportHeight;
const minHeight =
this._dialogMinViewpointHeight ??
(this.openAtContentHeight ? this._minViewportHeight() : 55);
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${`
--height: ${this._dialogViewportHeight}vh;
--height: ${this._dialogViewportHeight}dvh;
--max-height: ${this._dialogMaxViewpointHeight}vh;
--max-height: ${this._dialogMaxViewpointHeight}dvh;
--min-height: ${this._dialogMinViewpointHeight}vh;
--min-height: ${this._dialogMinViewpointHeight}dvh;
--max-height: ${maxHeight}vh;
--max-height: ${maxHeight}dvh;
--min-height: ${minHeight}vh;
--min-height: ${minHeight}dvh;
`}
>
<div class="handle-wrapper">
@@ -83,7 +124,9 @@ export class HaResizableBottomSheet extends LitElement {
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = 20;
this._dialogMinViewpointHeight = this._minViewportHeight();
this._opened = true;
this._fireResized();
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
@@ -102,6 +145,7 @@ export class HaResizableBottomSheet extends LitElement {
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
window.addEventListener("resize", this._handleViewportResize);
}
disconnectedCallback() {
@@ -113,8 +157,16 @@ export class HaResizableBottomSheet extends LitElement {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
window.removeEventListener("resize", this._handleViewportResize);
}
// minHeight is a pixel value, so its viewport-relative minimum depends on the
// window height; recompute it when the viewport changes (for example on
// rotation) so the cached percentage cannot clip the persistent peek content.
private _handleViewportResize = () => {
this._applyMinViewportHeight();
};
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
@@ -156,8 +208,10 @@ export class HaResizableBottomSheet extends LitElement {
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
if (this.persistent) {
newSize = Math.max(this._minViewportHeight(), newSize);
} else if (newSize < 20 && deltaY < 0) {
// on drag down and below 20vh
this._endDrag();
this.closeSheet();
return;
@@ -166,6 +220,45 @@ export class HaResizableBottomSheet extends LitElement {
this._dialogViewportHeight = newSize;
}
private _minViewportHeight(): number {
return this.minHeight === undefined
? 20
: (this.minHeight / window.innerHeight) * 100;
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
// A minimum set after opening applies right away
if (changedProperties.has("minHeight") && this._opened) {
this._applyMinViewportHeight();
}
}
// Applies the current pixel minimum as a viewport percentage and, once it has
// rendered, reports the size if it changed: raising the minimum can grow a
// sheet resting at the old one, and the host needs the new height to keep its
// overlay padding in step.
private _applyMinViewportHeight() {
if (!this._opened) {
return;
}
this._dialogMinViewpointHeight = this._minViewportHeight();
this.updateComplete.then(() => {
if (this._dialog.offsetHeight !== this._lastReportedHeight) {
this._fireResized();
}
});
}
private _lastReportedHeight?: number;
private _fireResized() {
this._lastReportedHeight = this._dialog.offsetHeight;
fireEvent(this, "bottom-sheet-resized", {
height: this._lastReportedHeight,
});
}
private _handleMouseUp = () => {
this._endDrag();
};
@@ -180,6 +273,8 @@ export class HaResizableBottomSheet extends LitElement {
}
this._dragging = false;
document.body.style.removeProperty("cursor");
// Hosts lay out around the sheet once, not on every move
this.updateComplete.then(() => this._fireResized());
}
static styles = css`
@@ -201,7 +296,8 @@ export class HaResizableBottomSheet extends LitElement {
justify-content: center;
align-items: center;
z-index: 7;
padding-bottom: 76px;
/* Extends the grab area over the content below the handle */
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
}
.handle-wrapper .handle::after {
content: "";
@@ -234,8 +330,20 @@ export class HaResizableBottomSheet extends LitElement {
top: 0;
inset-inline-start: 0;
position: fixed;
--sheet-inset-left: var(
--ha-bottom-sheet-inset-left,
var(--safe-area-inset-left)
);
--sheet-inset-right: var(
--ha-bottom-sheet-inset-right,
var(--safe-area-inset-right)
);
--sheet-border-width: var(--ha-bottom-sheet-border-width, 0px);
--sheet-side-borders: calc(2 * var(--sheet-border-width));
width: calc(
100% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
100% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
--sheet-inset-right
)
);
max-width: 100%;
border: none;
@@ -257,14 +365,14 @@ export class HaResizableBottomSheet extends LitElement {
);
transform: translateY(100%);
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-top-width: var(--sheet-border-width);
border-right-width: var(--sheet-border-width);
border-left-width: var(--sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
margin-left: var(--sheet-inset-left);
margin-right: var(--sheet-inset-right);
}
dialog.show {
@@ -280,5 +388,6 @@ declare global {
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
"bottom-sheet-resized": { height: number };
}
}
+1 -3
View File
@@ -164,9 +164,7 @@ export class HaSelect extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
@@ -72,9 +72,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
></ha-select-box>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -45,8 +45,11 @@ export class HaChooseSelector extends LitElement {
}
if (
changedProperties.has("value") &&
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
typeof this.value === "object" &&
this.value !== null &&
"active_choice" in this.value &&
this.value.active_choice in this.selector.choose.choices &&
this.value.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
@@ -150,14 +153,14 @@ export class HaChooseSelector extends LitElement {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object"
return typeof this.value === "object" && "active_choice" in this.value
? this.value[choice || this.value.active_choice]
: this.value;
}
private _setActiveChoice() {
if (this.value) {
if (typeof this.value === "object") {
if (typeof this.value === "object" && "active_choice" in this.value) {
if (this.value.active_choice in this.selector.choose.choices) {
this._activeChoice = this.value.active_choice;
return;
@@ -199,6 +202,22 @@ export class HaChooseSelector extends LitElement {
];
return;
}
if (
typeofValue === "object" &&
!Array.isArray(this.value) &&
Object.keys(this.value).length > 0 &&
Object.keys(this.value).every((key) =>
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
key
)
) &&
selectorTypes.includes("duration")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("duration")
];
return;
}
}
}
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
@@ -68,9 +68,7 @@ export class HaDateTimeSelector extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""
}
`;

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