Compare commits

...
94 Commits
Author SHA1 Message Date
Bram Kragten 26690e06b0 Bumped version to 20260930.4 2026-10-16 13:23:16 +02:00
Maarten Lakerveld e7a89e0ac0 Stop the user condition editor from duplicating selected users (#54921)
When the users finish loading, the list picks up the pre-selected items and
fires a selected event for them. The handler appended that user again on
every open of the editor, so a single user condition grew one duplicate id
per edit. Ignore selection events that do not change the configured users.

Fixes #54894
2026-10-16 13:22:57 +02:00
Paulus SchoutsenandClaude eb90a50ca1 Keep hidden columns when reopening data table settings (#54874)
Keep column settings for tables without fixed last columns

The data table settings dialog dropped the stored column order when the
table had no lastFixed columns. With no column order, the dialog showed
every column as visible. Reopening the dialog on the statistics table
then showed hidden columns as enabled again.


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

Co-authored-by: Claude <[email protected]>
2026-10-16 13:22:56 +02:00
MichaelandClaude Opus 5.5 e221ea2b19 Fix Matter add device button not opening the add device dialog (#54823)
The add device button on the Matter dashboard linked to
/config/matter/add, which navigated to the devices dashboard and opened
the Matter add device dialog without awaiting the navigation. The
navigation closes open dialogs before it completes, so the dialog was
closed again right away and the user ended up on the devices dashboard.

Open the dialog directly from the Matter dashboard, like the Z-Wave
dashboard does, and make the /config/matter/add route (used by the
add_matter_device my link) await the navigation before showing the
dialog from the app root, as its own element is detached by then.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 13:22:55 +02:00
Jan-Philipp Benecke abd5f9e985 Fix script graph crash on template shorthand conditions (#54798)
* Fix script graph crash on template shorthand conditions

* Move string to ConditionWithShorthand
2026-10-16 13:22:54 +02:00
Petar Petrov 2f9a4f99be Link Ethernet Matter nodes to Home Assistant in the network map (#54784) 2026-10-16 13:22:53 +02:00
Maarten Lakerveld 0cf9fcef97 Map overview fixes (#54774) 2026-10-16 13:21:22 +02:00
Bram KragtenandClaude Opus 5.5 3f7f4613d6 Remove invalid ::part() selector that broke CSS minification (#54772)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:29:05 +02:00
Bram Kragten 4a8501a6e5 Fix import from ui-support 2026-10-09 11:26:27 +02:00
Bram Kragten 4ae1775644 Bumped version to 20260930.3 2026-10-09 11:16:55 +02:00
Jan-Philipp Benecke 80cd338e77 Fix crash on null trigger entries in the automation editor (#54771) 2026-10-09 11:16:35 +02:00
Franck Nijhof cd8f785ce6 Don't count the characters of a string target (#54769) 2026-10-09 11:11:58 +02:00
Franck Nijhof 4f4f7bd633 Translate attribute names every entity can have (#54768)
* Translate attribute names every entity can have

* Translate the supported features attribute name
2026-10-09 11:11:57 +02:00
Franck Nijhof c2413fd7fa Greet in the interface language when it is a variant of the pipeline language (#54766) 2026-10-09 11:11:55 +02:00
Franck Nijhof ed596fe700 Hide hidden cards in grid sections without :has() (#54764) 2026-10-09 11:11:53 +02:00
Franck Nijhof e580fb8b5c Keep dropdown menus clear of safe areas (#54762) 2026-10-09 11:11:52 +02:00
Franck Nijhof 5ba7a906d4 Keep icon button labels off the ha-button host (#54761) 2026-10-09 11:11:51 +02:00
Franck Nijhof c6159615d7 Release streaming images when hui-image is detached (#54760) 2026-10-09 11:11:49 +02:00
Franck Nijhof b2f63cf345 Show the speech-to-text duration without VAD (#54759) 2026-10-09 11:11:48 +02:00
Franck Nijhof fbd7bb073e Give dialogs an accessible name (#54758) 2026-10-09 11:11:47 +02:00
Franck Nijhof 42fec4fb9c Expose the real invalid state of inputs to screen readers (#54757) 2026-10-09 11:11:46 +02:00
Franck Nijhof bcd7f0dc88 Show the ZHA config entry state when it isn't loaded (#54756) 2026-10-09 11:11:45 +02:00
Franck Nijhof 44b5dc9446 Fit the add integration list on short screens (#54755) 2026-10-09 11:11:43 +02:00
Franck Nijhof 4fd8732160 Fall back to local voice setup when the cloud status fails (#54754) 2026-10-09 11:11:42 +02:00
Petar Petrov 51675e0cd2 Fix negative upstream device untracked energy in devices detail graph (#54753) 2026-10-09 11:10:17 +02:00
Petar Petrov 36bddbff06 Hide Zigbee from the ESPHome device setup wizard (#54751) 2026-10-09 11:06:57 +02:00
Simon Lamon e8c15f10e2 Restore compatibillity with older chromium devices in ScrollFadeController (#54742)
Restore compatibillity with older chromium devices
2026-10-09 11:06:55 +02:00
Franck Nijhof 8f870146c6 Keep custom logbook messages for automations and scripts (#54739)
* Keep custom logbook messages for automations and scripts

* Only match the domain's own run message
2026-10-09 11:06:54 +02:00
Franck Nijhof 91c0d052df Convert CSS color names to hex when saving a label (#54738) 2026-10-09 11:06:53 +02:00
Franck Nijhof dfb5e7d768 Keep the label of color inputs raised (#54737) 2026-10-09 11:06:51 +02:00
Artur Pragacz 03fb6f5c29 Fix translation key for external config flow step description (#54722) 2026-10-09 11:06:50 +02:00
Maarten Lakerveld 11176a7593 Center the map on a single entity instead of fitting it (#54718)
The vector map's globe projection (new in 2026.10) cannot solve a camera
for a zero-area bound, so fitting a single-entity map warned "Map cannot
fit within canvas" and left the view on the default location. Center a
single point directly with easeTo/flyTo rather than routing it through
fitBounds, carrying the fit's padding as a transient offset and honouring
its fly request.
2026-10-09 11:06:48 +02:00
Paul Bottein 8a7b8ff2ac Add side padding to the statistics search field (#54717) 2026-10-09 11:06:47 +02:00
Franck Nijhof f61e1ab509 Respect custom icons for built-in panels in the sidebar (#54709) 2026-10-09 11:06:46 +02:00
Franck Nijhof 3e75d97b8a Show the right page title for more settings pages (#54708) 2026-10-09 11:06:45 +02:00
Franck Nijhof 8588d34fbe Only cache the frontend's own static files in the service worker (#54707) 2026-10-09 11:06:43 +02:00
Paul Bottein f4952764d4 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-09 11:06:42 +02:00
Franck Nijhof 52d38105ae Let long dropdown submenus scroll (#54692) 2026-10-09 11:06:41 +02:00
Franck Nijhof 8fa6c1d027 Keep a cleared input value when focusing it again (#54690) 2026-10-09 11:06:39 +02:00
Franck Nijhof 972b999195 Handle map labels without a name (#54689) 2026-10-09 11:06:38 +02:00
Petar Petrov fe6bc5530e Show the newest history states when the browser clock runs behind (#54688) 2026-10-09 11:06:37 +02:00
Franck Nijhof d5d7093ca3 Keep the key order when duplicating a script (#54687) 2026-10-09 11:06:36 +02:00
Franck Nijhof 52d30d5b6e Keep boolean fields inline in the narrow action editor (#54686) 2026-10-09 11:06:35 +02:00
Franck Nijhof 2a25673659 Fix ZHA reconfigure dialog errors after closing and on reconnect (#54684)
* Fix ZHA reconfigure dialog errors after closing and on reconnect

* Only handle reconfigure messages of the current run
2026-10-09 11:06:33 +02:00
Franck Nijhof 94838cf7d1 Load the HLS source once instead of on every media attach (#54683) 2026-10-09 11:06:32 +02:00
Franck Nijhof 9214bcac94 Wrap descriptions in voice assistant settings (#54678) 2026-10-09 11:06:30 +02:00
karwosts 3be6887df6 Fix create floor memoization issue (#54675) 2026-10-09 11:06:29 +02:00
Paul Bottein 33374693c9 Snap the map overview bottom sheet to fixed positions (#54663)
* Snap the map overview bottom sheet to fixed positions

* Include the safe area in the collapsed stop and follow reduced motion
2026-10-09 11:06:28 +02:00
Marcin BauerandClaude Opus 5.5 80516f1f00 Restore left-column timeline names on the history-graph card (#54661)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:06:26 +02:00
Petar Petrov 9253af6a19 Align history timeline X axis with line charts (#54657) 2026-10-09 11:06:25 +02:00
Bram KragtenandClaude Opus 5.5 58f1d51c7f Add realistic automations to the demo (#54647)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:04:03 +02:00
Bram KragtenandClaude Opus 5.5 05d6f614bc Show the next demo card on the home page demo again (#54646)
* Show the next demo card on the home page demo again

The home overview strategy now generates the favorites section itself
instead of returning a common-controls section strategy, so the demo
strategy no longer found the section to add its card to. Find the
favorites section by its heading instead.

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

* Keep at least six favorites next to the demo card

The section strategy used to get a limit of two less, so seven tiles
became six. Only drop as many tiles as needed to keep six.

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

* Keep the current user when the demo user subscription refreshes

The auth/current_user mock answered with the user from startup, so the
user subscription reset a changed user, like the non-admin scenario of
the e2e tests, to the initial admin user.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:44 +02:00
Bram KragtenandClaude Opus 5.5 6e2bb1b2f5 Mock editing the own profile in the demo (#54645)
The demo did not answer person/update_own_profile, so saving the profile
dialog showed an error. Also answer auth/current_user, so the user
subscription picks up the new name.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:43 +02:00
Bram KragtenandClaude Opus 5.5 f3a6e1028e Add focus indicator to entity and sensor cards (#54644)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:41 +02:00
WendelinandClaude f182b4600d Dismiss reboot/shutdown toast when frontend reconnects (#54640)
The "Rebooting system" toast was shown without an id and with no
timeout, so nothing could ever close it and it stayed on screen after
the host came back. Give it an id, dismiss it when the connection is
re-established, and also when the action fails with an error dialog.

Fixes #54630


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

Co-authored-by: Claude <[email protected]>
2026-10-09 11:00:40 +02:00
Jan Čermák b7983e0906 Copy locale data to the landing page build (#54488)
The landing page bundle includes the Intl polyfill. When a browser
needs a polyfill, it loads locale data from /static/locale-data/.
The landing page build never copied that directory, so these requests
returned 404. The language picker then showed raw language codes.

Fixes home-assistant/landingpage#216
2026-10-09 11:00:38 +02:00
Bram KragtenandClaude Opus 5 6c988150e4 Draw the globe against a sky when the map is zoomed out (#54485)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:37 +02:00
Bram KragtenandClaude Opus 5.5 b88a39e273 Cache map tiles per asset class and stop refetching what still works (#54451)
* Cache map tiles per asset class and stop refetching what still works

The service worker kept glyphs, sprites and tiles in one 500-entry LRU, so
panning around evicted the fonts that every label on every map needs. Glyphs
and sprites move to a cache of their own, on the 30 day age core already sends
them with, and the tile ceiling goes up.

The TileJSON was deliberately left on the network because it is the switching
point for the tile source. Every map waits on it before asking for a tile
though, and at a five minute max-age that is a round trip before the first one.
Served stale-while-revalidate instead: out of the cache immediately, refreshed
behind it, so a moved endpoint still arrives.

The raster fallback redrew on every token rotation, refetching every tile on
screen twice an hour whether or not anything had failed. Later tiles pick up a
new token by themselves, so only a layer that actually had one refused needs
asking again - and since a refused tile is the only sign that path gets that
its token is stale, it now asks for a new one instead of waiting for the
refresh interval to come round.

Style requests that are in flight are shared, so a dashboard of maps, or a
theme toggle switching all of them at once, fetches each style once. Only while
in flight: a settled request is dropped, or a style that failed once would
never be fetched again.

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

* Hand the raster fallback the current map tiles token

A rotation is only announced once, so a vector layer that later falls back
handed the raster layer the token it was created with, and the refresh that
follows a refused tile fetches the already-current token and announces
nothing. The vector layer now tracks rotations itself.

Also covers in-flight style sharing, and passes a map style rather than
`false` in the raster recovery tests.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 11:00:35 +02:00
Maarten Lakerveld ec6b258750 Zone editor: fit without passive zones, hide covered radius handles, name tooltips, and start new zones at the map center (#54597)
* Leave passive zones out of the zones map fit

Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595

* Hide a zone's radius handle while the circle is under its icon

Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.

* Show a zone's name in a tooltip when hovering its marker on the zones map

The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.

* Start a new zone at the center of the zones map

Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-07 10:12:54 +02:00
Maarten Lakerveld ea1fabdfdd Map: float every marker, keep the selection in the URL, pan-first focus, and fix attribution and zone radius display (#54596)
* Auto-hide compact map attribution and restyle it for dark mode

MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.

* Add show_zone_radius option to the map card

In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.

* Regroup map clusters up front for every fit

Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.

* Return the map to its previous view from the overview's back button

Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.

* Float the selected map marker above a dot at its location

Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.

* Open more info from a button in the map overview detail

The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.

* Always show the selected marker's accuracy circle

The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.

* Describe every toggle in the map card editor

Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.

* Float every map marker above its location

Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.

* Keep the map overview selection in browser history

The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.

* Pan to a selected map entity instead of zooming in on it

Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.

* Float every cluster bubble above its location

Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-07 10:12:53 +02:00
Bram Kragten af4f86743b Bumped version to 20260930.2 2026-10-07 10:07:02 +02:00
Artur Pragacz ed492d5b94 Consider next name part when unprefixing entity name (#54633)
* Consider next name part when unprefixing entity name

* Adjust
2026-10-07 10:05:34 +02:00
Paul Botteinandcopilot-swe-agent[bot] db00a6b7e7 Fix section editor crash while typing a strategy in YAML (#54622)
* Fix section editor crash while typing a strategy in YAML

* Reject array-valued section strategies in YAML

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 10:05:33 +02:00
WendelinandClaude 864f0877f6 Increase touch area of ha-checkbox (#54617)
Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:31 +02:00
Wendelin d7062f3d9d Fix HaChipBase border radius (#54603)
Add border-radius to HaChipBase styles for improved appearance
2026-10-07 10:05:30 +02:00
Jonathan 3051032360 Show the requested run when a trace page switches item (#54591) 2026-10-07 10:05:29 +02:00
Paul Bottein 54c575efd1 Show referenced triggers instead of generated IDs in automation and trace descriptions (#54587) 2026-10-07 10:05:28 +02:00
Paul Bottein e6bc47e6ba Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-07 10:05:26 +02:00
WendelinandClaude Sonnet 5.5 6244a8db04 Fix config entry state styling (#54584)
* Restore needs attention color on config entry rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore config entry row state styling

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Highlight config entry state with border color instead of tint

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add quiet fill background to config entry states

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic colors for config entry state borders

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Drop state border colors and use semantic text colors

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic color for config entry link icon

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add divider between discovered and attention flow rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic border and fill colors for discovered and attention lists

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use warning colors for discovered list too

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use standard divider color for flow list borders

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

* Use border width token for flow list borders

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

* Restore warning icon color for failed unload state

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

* review

---------

Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:25 +02:00
Paul Bottein 3f2c1ea538 Show then branch before else in trace graph (#54583) 2026-10-07 10:05:24 +02:00
Paul Bottein aac947ee4d Fix option labels overflowing horizontal control selects (#54582) 2026-10-07 10:05:23 +02:00
Maarten Lakerveld 594d9be859 Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-07 10:05:22 +02:00
Paul Bottein 80ae2543e5 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-07 10:05:21 +02:00
Wendelin 94d176c3d1 Fix styling for config entry device rows (#54570) 2026-10-07 10:05:20 +02:00
Jan-Philipp Benecke 192d9f785f Hide dangling trace graph connectors when nothing follows (#54568) 2026-10-07 10:05:18 +02:00
Paul Bottein 398ffa045b Fix first badges cut off in centered scrolling view header (#54565) 2026-10-07 10:05:17 +02:00
Wendelin e67538fb7c config-area-page fix missing list-item import (#54563) 2026-10-07 10:05:16 +02:00
Bram Kragten 025db7ffe7 Bumped version to 20260930.1 2026-10-05 11:02:45 +02:00
Paulus SchoutsenandClaude f9c958c80c Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


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

Co-authored-by: Claude <[email protected]>
2026-10-05 11:02:26 +02:00
Jan-Philipp Benecke 652897a3cb Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-05 11:02:25 +02:00
Maarten Lakerveld 3426449315 Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-05 11:02:24 +02:00
Jan-Philipp Benecke 8e19b0249d Fix misplaced trace graph icons on iOS (#54520) 2026-10-05 11:02:23 +02:00
Paul Bottein bba034eeca Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-05 11:02:21 +02:00
karwosts 31f2767038 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-05 11:00:21 +02:00
Petar Petrov e0571044ae Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-05 10:57:27 +02:00
Maarten Lakerveld 0f00286641 Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-05 10:57:26 +02:00
Petar Petrov 9cc166d3cf Fix double side padding around more-info history and activity (#54511) 2026-10-05 10:57:25 +02:00
Bram Kragten 3ad1ed5aa6 Resolve split devices in the logbook card (#54507) 2026-10-05 10:57:24 +02:00
Wendelin 8ee84c2462 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-05 10:57:22 +02:00
Paul Bottein eca108032f Fix area switch for entities without their own name (#54503) 2026-10-05 10:57:21 +02:00
Paul Bottein 3bfe67f784 Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-05 10:57:20 +02:00
Paul Bottein c27191de6b Show people and zones on the demo map (#54496) 2026-10-05 10:57:19 +02:00
Bram Kragten 31f626c877 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-10-05 10:57:17 +02:00
Tom Mittelstädtand1358 b081db7020 Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-05 10:57:16 +02:00
174 changed files with 15857 additions and 2457 deletions

No files matched your search

+4
View File
@@ -54,6 +54,10 @@ http://localhost:8090/?demo=<second slug>#/energy/water
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Core Data
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
+29 -8
View File
@@ -5,6 +5,7 @@ import mapStream from "map-stream";
import transform from "gulp-json-transform";
import { LokaliseApi } from "@lokalise/node-api";
import JSZip from "jszip";
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
const inDir = "translations";
const inDirFrontend = `${inDir}/frontend`;
@@ -51,15 +52,34 @@ function convertBackendTranslations(data, _file) {
if (!data.component) {
return output;
}
// Keep the entity domains and the domains with automation triggers or
// conditions, so the demo can show states and describe automations. Only
// keep the services of the domains whose services the demo mocks.
Object.keys(data.component).forEach((domain) => {
if (!("entity_component" in data.component[domain])) {
const {
entity_component,
services,
triggers,
conditions,
selector,
title,
} = data.component[domain];
if (
!entity_component &&
!triggers &&
!conditions &&
!SERVICE_DOMAINS.includes(domain)
) {
return;
}
output.component[domain] = { entity_component: {} };
Object.keys(data.component[domain].entity_component).forEach((key) => {
output.component[domain].entity_component[key] =
data.component[domain].entity_component[key];
});
output.component[domain] = {
entity_component,
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
triggers,
conditions,
selector,
title,
};
});
return output;
}
@@ -155,8 +175,9 @@ gulp.task("fetch-lokalise", async function () {
fs.mkdir(inDirBackend, { recursive: true }),
]);
// The backend project only provides entity_component translations, which are
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
// The backend project only provides the translations the mocked backend
// needs (see convertBackendTranslations), which are merged into the demo,
// gallery, cast and e2e builds. The shipped app fetches
// them live from core, so builds that only produce the app (release, release
// landing-page) can skip this second, whole-project export to save time.
const projects = Object.entries(lokaliseProjects).filter(
+1
View File
@@ -177,6 +177,7 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
copyLocaleData(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
+422
View File
@@ -0,0 +1,422 @@
// Generates the service, trigger and condition descriptions the demo and
// gallery serve instead of a backend, from the files in Home Assistant Core.
import gulp from "gulp";
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import prettier from "prettier";
import paths from "../paths.cjs";
const CORE_URL =
process.env.CORE_COMPONENTS_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
// Integrations whose services the mocked backend provides
export const SERVICE_DOMAINS = [
"alarm_control_panel",
"automation",
"button",
"calendar",
"camera",
"climate",
"conversation",
"counter",
"cover",
"device_tracker",
"fan",
"frontend",
"group",
"homeassistant",
"humidifier",
"image_processing",
"input_boolean",
"input_button",
"input_datetime",
"input_number",
"input_select",
"input_text",
"lawn_mower",
"light",
"lock",
"logger",
"media_player",
"notify",
"number",
"persistent_notification",
"person",
"recorder",
"remote",
"scene",
"schedule",
"script",
"select",
"siren",
"switch",
"system_log",
"text",
"timer",
"todo",
"tts",
"update",
"vacuum",
"valve",
"water_heater",
"weather",
"zone",
];
// Services that return data. Core sets this when it registers a service, it is
// not part of services.yaml. The value is whether the response is optional.
const RESPONSE_SERVICES = {
"calendar.get_events": false,
"conversation.process": true,
"media_player.browse_media": false,
"media_player.search_media": false,
"recorder.get_statistics": false,
"schedule.get_schedule": false,
"todo.get_items": false,
"weather.get_forecasts": false,
};
// Services that are still in services.yaml, but that core no longer registers
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
// Integrations that provide the new style triggers and conditions
const AUTOMATION_PLATFORM_DOMAINS = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
// YAML 1.2 with merge keys, which matches how core reads these files
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with \`script/gen_demo_core_data\`.`;
const fetchCoreFile = async (domain, file) => {
const url = `${CORE_URL}/${domain}/${file}`;
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
};
// Fetches a file of every domain, keyed by domain
const fetchCoreFiles = async (domains, file) =>
Object.fromEntries(
await Promise.all(
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
)
);
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
// Enum references look like "cover.CoverEntityFeature.OPEN"
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
const enumDomains = (value, domains = new Set()) => {
if (typeof value === "string") {
const match = value.match(ENUM_REFERENCE);
if (match) {
domains.add(match[1]);
}
} else if (value && typeof value === "object") {
Object.values(value).forEach((val) => enumDomains(val, domains));
}
return domains;
};
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
const enumResolver = async (yamls) => {
const domains = [...enumDomains(yamls)];
const [consts, inits] = await Promise.all([
fetchCoreFiles(domains, "const.py"),
fetchCoreFiles(domains, "__init__.py"),
]);
return (reference) => {
const [domain, enumName, member] = reference.split(".");
for (const source of [consts[domain], inits[domain]]) {
const match = source
?.split(`class ${enumName}(`)[1]
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
if (match) {
return JSON.parse(match[1]);
}
}
throw new Error(`Unknown enum ${reference}`);
};
};
const createResolvers = (enumValue) => {
// In entity filters, every item of the supported features, a feature or a
// list of features, becomes one bit mask
const featureMask = (features) =>
[].concat(features).reduce(
// eslint-disable-next-line no-bitwise
(mask, feature) => mask | enumValue(feature),
0
);
const resolveSelector = (value) => {
if (Array.isArray(value)) {
return value.map(resolveSelector);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [
key,
key === "supported_features"
? [].concat(val).map(featureMask)
: resolveSelector(val),
])
);
};
// Like core, an empty target allows any target
const resolveTarget = (target) => resolveSelector(target ?? {});
// Field filters keep their lists, with every enum resolved on its own
const resolveField = (field) => {
const result = { ...field };
if (field.selector) {
result.selector = resolveSelector(field.selector);
}
if (field.filter?.supported_features) {
result.filter = {
...result.filter,
supported_features: field.filter.supported_features.map(enumValue),
};
}
if (field.filter?.attribute) {
result.filter = {
...result.filter,
attribute: Object.fromEntries(
Object.entries(field.filter.attribute).map(([name, options]) => [
name,
options.map(enumValue),
])
),
};
}
return result;
};
// Fields can be grouped in sections, which hold fields themselves
const resolveFields = (fields) =>
Object.fromEntries(
Object.entries(fields ?? {}).map(([key, field]) => [
key,
field && "fields" in field && !("selector" in field)
? { ...field, fields: resolveFields(field.fields) }
: resolveField(field ?? {}),
])
);
return { resolveFields, resolveTarget };
};
// Mirrors how core builds the response of get_services
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
SERVICE_DOMAINS.map((domain) => {
if (!yamls[domain]) {
throw new Error(`No services.yaml found for ${domain}`);
}
const services = Object.entries(yamls[domain])
.filter(
([service]) =>
!service.startsWith(".") &&
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
)
.map(([service, value]) => {
const description = { fields: resolveFields(value?.fields) };
for (const item of ["description", "name"]) {
if (value && item in value) {
description[item] = value[item];
}
}
if (value && "target" in value) {
description.target = resolveTarget(value.target);
}
const key = `${domain}.${service}`;
if (key in RESPONSE_SERVICES) {
description.response = { optional: RESPONSE_SERVICES[key] };
}
return [service, description];
});
return [domain, Object.fromEntries(services)];
})
);
// Mirrors how core builds the trigger and condition platform descriptions
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
Object.entries(yamls[domain] ?? {})
// Keys starting with a dot only hold YAML anchors
.filter(([key, value]) => !key.startsWith(".") && value)
.map(([key, value]) => {
const absoluteKey = !key.startsWith("_")
? `${domain}.${key}`
: key.slice(1) || domain;
const description = { fields: resolveFields(value.fields) };
if ("target" in value) {
description.target = resolveTarget(value.target);
}
return [absoluteKey, description];
})
)
);
// The trigger or condition icons of every domain that has them
const platformIcons = (icons, category) =>
Object.fromEntries(
Object.entries(icons)
.map(([domain, content]) => [
domain,
content ? JSON.parse(content)[category] : undefined,
])
.filter(([, categoryIcons]) => categoryIcons)
);
const parseYamls = (files) =>
Object.fromEntries(
Object.entries(files)
.filter(([, content]) => content !== undefined)
.map(([domain, content]) => [domain, parseYaml(content)])
);
const writeTs = async (path, content) => {
const file = join(paths.root_dir, path);
const options = await prettier.resolveConfig(file);
await writeFile(
file,
await prettier.format(`${HEADER}\n${content}`, {
...options,
filepath: file,
})
);
};
gulp.task("gen-demo-core-data", async () => {
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
await Promise.all([
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
]);
const servicesYamls = parseYamls(servicesFiles);
const triggersYamls = parseYamls(triggersFiles);
const conditionsYamls = parseYamls(conditionsFiles);
const resolvers = createResolvers(
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
);
for (const key of UNREGISTERED_SERVICES) {
const [domain, service] = key.split(".");
if (!(service in (servicesYamls[domain] ?? {}))) {
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
}
}
const services = serviceDescriptions(servicesYamls, resolvers);
for (const key of Object.keys(RESPONSE_SERVICES)) {
const [domain, service] = key.split(".");
if (!services[domain]?.[service]) {
throw new Error(`Service ${key} that returns a response does not exist`);
}
}
const triggers = platformDescriptions(triggersYamls, resolvers);
const conditions = platformDescriptions(conditionsYamls, resolvers);
const icons = {
triggers: platformIcons(iconsFiles, "triggers"),
conditions: platformIcons(iconsFiles, "conditions"),
};
await Promise.all([
writeTs(
"src/fake_data/demo_services.ts",
`import type { HassServices } from "home-assistant-js-websocket";
// Core sends more than the HassServices type covers, like sections and list
// examples.
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
`
),
writeTs(
"demo/src/stubs/automation_platform_domains.ts",
`
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_data.ts",
`import type { ConditionDescriptions } from "../../../src/data/condition";
import type { TriggerDescriptions } from "../../../src/data/trigger";
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_icons.ts",
`
export const triggerIcons = ${JSON.stringify(icons.triggers)};
export const conditionIcons = ${JSON.stringify(icons.conditions)};
`
),
]);
});
+1
View File
@@ -9,6 +9,7 @@ import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-demo-core-data.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
+96 -2
View File
@@ -1,12 +1,16 @@
import { navigate } from "../../../src/common/navigate";
import { slugify } from "../../../src/common/string/slugify";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
import { energyEntities } from "../stubs/entities";
import { connectivityEntities } from "../stubs/connectivity/fixtures";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
@@ -16,6 +20,95 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
hass.mockTheme(null, getDemoTheme(theme));
};
export const automationEntity = ({
config: automation,
entityId,
state = "on",
icon,
lastTriggered,
}: DemoAutomation): EntityInput => ({
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
platform: "automation",
state,
attributes: {
id: automation.id,
friendly_name: automation.alias,
last_triggered:
lastTriggered === undefined
? null
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
mode: automation.mode ?? "single",
current: 0,
// Like core, only the modes that can run more than once have a maximum
...((automation.mode === "queued" || automation.mode === "parallel") && {
max: automation.max ?? 10,
}),
icon,
},
});
// Automations saved in the editor during this session, per demo
// Automations saved in the editor during this session, per demo. A deleted
// automation is null.
const savedAutomations: Record<
string,
Record<string, DemoAutomation | null>
> = {};
export const saveDemoAutomation = (
id: string,
automation: DemoAutomation | null
) => {
savedAutomations[selectedDemo] = {
...savedAutomations[selectedDemo],
[id]: automation,
};
};
// The automations of a demo config, with the ones saved in the editor
export const demoAutomations = (
demo: string,
config: DemoConfig
): DemoAutomation[] => {
const saved = savedAutomations[demo] ?? {};
const configured = config.automations ?? [];
const ids = new Set(configured.map((automation) => automation.config.id));
return [
...configured.map((automation) =>
automation.config.id in saved ? saved[automation.config.id] : automation
),
...Object.values(saved).filter(
(automation) => automation && !ids.has(automation.config.id)
),
].filter((automation): automation is DemoAutomation => automation !== null);
};
// The automation entities are derived from the configs the editor shows, so
// their names and IDs always match.
export const demoConfigEntities = (
demo: string,
config: DemoConfig,
localize: LocalizeFunc
): EntityInput[] => [
...config.entities(localize),
...demoAutomations(demo, config).map(automationEntity),
];
// Like core, the automations of a demo config are in the entity registry
export const registerDemoAutomations = (
hass: MockHomeAssistant,
demo: string,
config: DemoConfig
) =>
setPlatformEntityRegistryEntries(
hass,
"automation",
demoAutomations(demo, config).map((automation) => ({
entity_id: automationEntity(automation).entity_id,
unique_id: automation.config.id,
}))
);
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
sections: () => import("./sections").then((mod) => mod.demoSections),
home: () => import("./home").then((mod) => mod.demoHome),
@@ -52,7 +145,8 @@ export const setDemoConfig = async (
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
registerDemoAutomations(hass, demo, config);
hass.addEntities(energyEntities());
// Replaced the whole state map above, so the entities that do not belong to a
// demo config have to be added back.
+27 -23
View File
@@ -7,7 +7,7 @@ import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelac
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
import type { HeadingCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { HomeAssistant } from "../../../../src/types";
export interface DemoHomeDashboardStrategyConfig extends Omit<
@@ -61,29 +61,33 @@ class DemoHomeOverviewViewStrategy extends ReactiveElement {
{ ...config, type: "home-overview" },
hass
);
// Expand the favorites section so the demo card can be added to it
const sections = await Promise.all(
(view.sections || []).map(async (section) => {
if (
!isStrategySection(section) ||
section.strategy.type !== "common-controls"
) {
return section;
}
// The demo card takes up the space of two tiles
const limit = (section.strategy.limit as number | undefined) ?? 8;
const favorites = await generateLovelaceSectionStrategy(
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
hass
);
const [heading, ...cards] = favorites.cards || [];
return {
...favorites,
// Place the demo card first so the tiles fill the rows next to it
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
};
})
// Add the demo card to the favorites section, found by its heading
const favoritesHeading = hass.localize(
"ui.panel.lovelace.strategy.home.favorites"
);
const sections = (view.sections || []).map((section) => {
if (
isStrategySection(section) ||
!section.cards?.some(
(card) =>
card.type === "heading" &&
(card as HeadingCardConfig).heading === favoritesHeading
)
) {
return section;
}
const [heading, ...cards] = section.cards;
return {
...section,
// Place the demo card first so the tiles fill the rows next to it. It
// takes up the space of two tiles, so keep two less, but at least six.
cards: [
heading,
{ type: "custom:ha-demo-next-card" },
...cards.slice(0, Math.max(6, cards.length - 2)),
],
};
});
return {
...view,
+361
View File
@@ -0,0 +1,361 @@
import type { DemoAutomation } from "../types";
export const demoAutomationsSections: DemoAutomation[] = [
{
lastTriggered: 60 * 9,
state: "off",
icon: "mdi:auto-mode",
config: {
id: "1700669321947",
alias: "Home Assistant Auto-update",
description:
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
triggers: [{ trigger: "time", at: "03:00:00" }],
conditions: [
{
condition: "update.is_available",
target: { entity_id: "update.home_assistant_core_update" },
options: { for: "00:00:00" },
},
],
actions: [
{
action: "update.install",
target: { entity_id: "update.home_assistant_core_update" },
data: { backup: true },
},
],
mode: "queued",
},
},
{
lastTriggered: 25,
config: {
id: "1700669322001",
alias: "Kitchen lights on motion",
description:
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
data: { brightness_pct: 80 },
},
{
wait_for_trigger: [
{
trigger: "motion.cleared",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:02:00" },
},
],
},
{
action: "light.turn_off",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 3,
config: {
id: "1700669322002",
alias: "Outdoor light from sunset to sunrise",
description:
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
triggers: [
{
trigger: "sun.sunset",
id: "sunset",
options: {
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
offset_type: "before",
},
},
{
trigger: "sun.sunrise",
id: "sunrise",
options: {
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
offset_type: "before",
},
},
],
conditions: [],
actions: [
{
choose: [
{
conditions: [{ condition: "trigger", id: "sunset" }],
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.outdoor_light" },
},
],
},
{
conditions: [{ condition: "trigger", id: "sunrise" }],
sequence: [
{
action: "light.turn_off",
target: { entity_id: "light.outdoor_light" },
},
],
},
],
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 14,
config: {
id: "1700669322003",
alias: "Flood light on motion when dark",
description:
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
options: { for: "00:00:00" },
},
],
conditions: [
{
condition: "illuminance.is_value",
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
options: {
threshold: {
type: "below",
value: { number: 50, unit_of_measurement: "lx" },
},
for: "00:00:00",
},
},
],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.flood_light" },
},
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
{
action: "light.turn_off",
target: { entity_id: "light.flood_light" },
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 26,
config: {
id: "1700669322004",
alias: "Close the shutters when it gets hot",
description:
"Keeps the living room cool by closing the shutters on hot, sunny days.",
triggers: [
{
trigger: "temperature.crossed_threshold",
target: { entity_id: "sensor.outdoor_temperature" },
options: {
threshold: {
type: "above",
value: { number: 25, unit_of_measurement: "°C" },
},
for: "00:10:00",
},
},
],
conditions: [{ condition: "sun.is_up" }],
actions: [
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
],
},
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 50,
config: {
id: "1700669322005",
alias: "Fridge door left open",
description:
"Sends a reminder when the fridge door has been open for 2 minutes.",
triggers: [
{
trigger: "door.opened",
target: { entity_id: "binary_sensor.fridge_door" },
options: { for: "00:02:00" },
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
title: "Fridge door",
message: "The fridge door has been open for 2 minutes.",
},
},
],
mode: "single",
},
},
{
lastTriggered: 90,
config: {
id: "1700669322006",
alias: "Meeting mode",
description:
"Pauses the music in the study and turns on the lights when a meeting starts.",
triggers: [
{
trigger: "switch.turned_on",
target: { entity_id: "switch.in_meeting" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "media_player.media_pause",
target: { entity_id: "media_player.study_nest_hub" },
},
{
action: "light.turn_on",
target: { entity_id: "light.study_spotlights" },
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 20,
config: {
id: "1700669322009",
alias: "Lights up when the music pauses",
description:
"Turns on the living room spotlights when the music is paused in the evening.",
triggers: [
{
trigger: "media_player.paused_playing",
target: { entity_id: "media_player.living_room_nest_mini" },
options: { for: "00:00:00" },
},
],
conditions: [{ condition: "sun.is_set" }],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.living_room_spotlights" },
data: { brightness_pct: 40 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 7,
config: {
id: "1700669322007",
alias: "Lower heating when the car leaves",
triggers: [
{
trigger: "zone.left",
target: { entity_id: "device_tracker.car" },
options: { zone: "zone.home", for: "00:00:00" },
},
],
conditions: [
{
condition: "time",
weekday: ["mon", "tue", "wed", "thu", "fri"],
},
],
actions: [
{
action: "climate.set_temperature",
target: {
entity_id: ["climate.ground_floor", "climate.first_floor"],
},
data: { temperature: 17 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 11,
config: {
id: "1700669322008",
alias: "Good night",
description:
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
triggers: [
{
trigger: "time",
at: "23:00:00",
weekday: ["sun", "mon", "tue", "wed", "thu"],
},
],
conditions: [],
actions: [
{
action: "light.turn_off",
target: {
entity_id: [
"light.floor_lamp",
"light.living_room_spotlights",
"light.bar_lamp",
"light.kitchen_spotlights",
"light.worktop_spotlights",
"light.study_spotlights",
],
},
},
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
"cover.kitchen_shutter",
"cover.study_shutter",
],
},
},
],
mode: "single",
},
},
];
+12 -13
View File
@@ -140,6 +140,18 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor humidity",
},
},
// Used by the automation for when the car leaves home
"zone.home": {
entity_id: "zone.home",
state: "0",
attributes: {
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
},
},
"device_tracker.car": {
entity_id: "device_tracker.car",
state: "not_home",
@@ -514,19 +526,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor motion sensor Illuminance",
},
},
"automation.home_assistant_auto_update": {
entity_id: "automation.home_assistant_auto_update",
state: "off",
attributes: {
id: "1700669321947",
last_triggered: "2024-02-29T18:02:05.343139+00:00",
mode: "queued",
current: 0,
max: 50,
icon: "mdi:auto-mode",
friendly_name: "Home Assistant Auto-update",
},
},
"update.home_assistant_operating_system_update": {
entity_id: "update.home_assistant_operating_system_update",
state: "off",
+2
View File
@@ -1,4 +1,5 @@
import type { DemoConfig } from "../types";
import { demoAutomationsSections } from "./automations";
import { demoEntitiesSections } from "./entities";
import { demoLovelaceSections } from "./lovelace";
@@ -8,5 +9,6 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
automations: demoAutomationsSections,
theme: { theme: "default", dark: false },
};
+12
View File
@@ -1,5 +1,6 @@
import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { ManualAutomationConfig } from "../../../src/data/automation";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
@@ -8,6 +9,16 @@ import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoAutomation {
config: ManualAutomationConfig & { id: string; alias: string };
/** Defaults to the alias as slug */
entityId?: string;
state?: "on" | "off";
icon?: string;
/** Minutes ago */
lastTriggered?: number;
}
export interface DemoConfig {
index?: number;
name: string;
@@ -17,6 +28,7 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
automations?: DemoAutomation[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
+39 -12
View File
@@ -1,11 +1,18 @@
import { customElement } from "lit/decorators";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { navigate } from "../../src/common/navigate";
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import {
applyDemoTheme,
demoConfigEntities,
registerDemoAutomations,
selectedDemo,
selectedDemoConfig,
} from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
@@ -18,7 +25,10 @@ import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import {
mockEntityRegistry,
mockEntityRegistryDisplay,
} from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
@@ -26,9 +36,11 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
import { mockLovelace } from "./stubs/lovelace";
import { zoneRegistryEntries } from "./stubs/map";
import { mockMediaPlayer } from "./stubs/media_player";
import { mockPersistentNotification } from "./stubs/persistent_notification";
import { mockRecorder } from "./stubs/recorder";
@@ -57,6 +69,12 @@ const CONFIG_PANEL_COMMANDS = [
"system_health/",
"backup/",
"automation/config",
"trigger_platforms/subscribe",
"condition_platforms/subscribe",
"get_triggers_for_target",
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -87,14 +105,18 @@ export class HaDemo extends HomeAssistantAppEl {
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
// Some automation platforms are loaded already
...new Set([
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
...automationPlatformDomains,
]),
],
},
});
@@ -102,7 +124,9 @@ export class HaDemo extends HomeAssistantAppEl {
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
() => this.hass!.localize
// hass is not updated yet when another fragment started loading
// meanwhile, so use the localize with these translations directly
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise);
@@ -137,6 +161,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -181,6 +206,7 @@ export class HaDemo extends HomeAssistantAppEl {
modified_at: 0,
},
...connectivityEntityRegistryEntries,
...zoneRegistryEntries,
]);
hass.addEntities(energyEntities());
@@ -191,7 +217,8 @@ export class HaDemo extends HomeAssistantAppEl {
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
registerDemoAutomations(hass, selectedDemo, conf);
applyDemoTheme(hass, conf.theme);
}
);
+15
View File
@@ -1,3 +1,4 @@
import type { OwnProfileMutableParams } from "../../../src/data/person";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAuth = (hass: MockHomeAssistant) => {
@@ -6,4 +7,18 @@ export const mockAuth = (hass: MockHomeAssistant) => {
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
path: msg.path,
}));
let user = hass.user!;
// The profile dialog refreshes the user subscription after saving, which
// writes the updated user back to hass.user.
hass.mockWS("auth/current_user", () => user);
hass.mockWS(
"person/update_own_profile",
(msg: Partial<OwnProfileMutableParams>) => {
if (msg.name) {
user = { ...user, name: msg.name };
}
return { user_name: user.name, person: null };
}
);
};
+142 -56
View File
@@ -1,69 +1,155 @@
import type { AutomationConfig } from "../../../src/data/automation";
import type { ScriptConfig } from "../../../src/data/script";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import { slugify } from "../../../src/common/string/slugify";
import {
automationEntity,
demoAutomations,
saveDemoAutomation,
selectedDemo,
selectedDemoConfig,
} from "../configs/demo-configs";
import type { DemoAutomation } from "../configs/types";
import { mockAutomationPlatforms } from "./automation_platforms";
import {
addEntityRegistryEntry,
getEntityRegistryEntry,
removeEntityRegistryEntry,
} from "./entity_registry";
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
id: entityId.split(".")[1],
alias: "Demo automation",
description: "An example automation shown in the demo.",
triggers: [
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
const automationConfig = async (
id: string | undefined
): Promise<AutomationConfig> => {
const automation = demoAutomations(
selectedDemo,
await selectedDemoConfig
).find((a) => a.config.id === id);
if (!automation) {
throw new Error("Automation not found");
}
return automation.config;
};
const demoScriptConfig = (): ScriptConfig => ({
alias: "Demo script",
description: "An example script shown in the demo.",
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
const scriptConfig = (
currentHass: MockHomeAssistant,
entityId: string
): ScriptConfig => {
const alias =
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
return {
alias,
sequence: [
{
action: "persistent_notification.create",
data: { message: `${alias} ran` },
},
],
mode: "single",
};
};
export const mockAutomation = (hass: MockHomeAssistant) => {
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
config: demoAutomationConfig(msg.entity_id),
}));
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
hass.mockAPI(/config\/automation\/config\/.+/, () =>
demoAutomationConfig("automation.demo")
);
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
// Trigger/condition type pickers subscribe for integration-provided
// platforms. The demo only uses the built-in ones, so emit empty records.
hass.mockWS(
"trigger_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
"automation/config",
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: await automationConfig(
currentHass.states[msg.entity_id]?.attributes.id
),
})
);
hass.mockAPI(
/config\/automation\/config\/.+/,
(currentHass, method, path, parameters) => {
const id = decodeURIComponent(path.split("/").pop()!);
const existing = Object.values(currentHass.states).find(
(stateObj) =>
computeDomain(stateObj.entity_id) === "automation" &&
stateObj.attributes.id === id
);
if (method === "DELETE") {
// Like core, remove the automation with its entity
saveDemoAutomation(id, null);
if (existing) {
const { [existing.entity_id]: _state, ...states } =
currentHass.states;
const { [existing.entity_id]: _entity, ...entities } =
currentHass.entities;
currentHass.updateHass({ states, entities });
// Otherwise a service call to it would add its state again
delete currentHass.mockEntities[existing.entity_id];
removeEntityRegistryEntry(currentHass, existing.entity_id);
}
return { result: "ok" };
}
if (method === "POST") {
// Core stores the ID in the config
const config = {
...(parameters as DemoAutomation["config"]),
id,
};
config.alias ||= id;
// Like core, update the automation entity, or add it for a new one
// A new automation gets a free entity ID, like core
const slug = `automation.${slugify(config.alias)}`;
let entityId = existing?.entity_id ?? slug;
for (let i = 2; !existing && entityId in currentHass.states; i++) {
entityId = `${slug}_${i}`;
}
const lastTriggered = existing?.attributes.last_triggered;
const automation: DemoAutomation = {
config,
entityId,
state: existing?.state as DemoAutomation["state"],
icon: existing?.attributes.icon,
lastTriggered: lastTriggered
? (Date.now() - Date.parse(lastTriggered)) / 60000
: undefined,
};
// Kept per demo, so it is still there after switching demos
saveDemoAutomation(id, automation);
// A display entry of a new automation is left over from another demo
const display = existing ? currentHass.entities[entityId] : undefined;
currentHass.addEntities(automationEntity(automation));
// Adding the entity resets its display entry, keep what the registry
// holds
if (display) {
const registryName = getEntityRegistryEntry(entityId)?.name;
currentHass.entities[entityId] = {
...currentHass.entities[entityId],
...(registryName && { name: registryName }),
area_id: display.area_id,
labels: display.labels,
icon: display.icon,
hidden: display.hidden,
};
}
if (!existing) {
addEntityRegistryEntry(currentHass, {
entity_id: entityId,
platform: "automation",
unique_id: id,
});
}
return { result: "ok" };
}
return automationConfig(id);
}
);
// Scripts do not have demo configs yet, show the script under its own name.
hass.mockWS(
"condition_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
}
"script/config",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: scriptConfig(currentHass, msg.entity_id),
})
);
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
scriptConfig(
currentHass,
`script.${decodeURIComponent(path.split("/").pop()!)}`
)
);
mockAutomationPlatforms(hass);
};
@@ -0,0 +1,52 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
+270
View File
@@ -0,0 +1,270 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
interface EntityFilter {
integration?: string;
domain?: string | string[];
device_class?: string | string[];
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
filter: EntityFilter
) =>
(!filter.integration ||
hass.entities[entityId]?.platform === filter.integration) &&
(!filter.domain ||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
(!filter.device_class ||
ensureArray(filter.device_class).includes(
hass.states[entityId]?.attributes.device_class ?? ""
)) &&
// Like core, the entity needs all the features of one of the feature sets
(!filter.supported_features ||
filter.supported_features.some(
(features) =>
// eslint-disable-next-line no-bitwise
((hass.states[entityId]?.attributes.supported_features ?? 0) &
features) ===
features
));
type TargetDescriptions = Record<
string,
{
target?: {
entity?: EntityFilter | EntityFilter[];
primary_entities_only?: boolean;
} | null;
}
>;
// Like core, the components with a target that apply to at least one entity
// of the target. A target without entity filters applies to any entity.
const componentsForTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
descriptions: TargetDescriptions
) => {
const entityIds = expandTarget(hass, target, false).entities;
const targetedIds = ensureArray(target.entity_id ?? []);
return Object.entries(descriptions)
.filter(([, description]) => {
if (!description.target) {
return false;
}
const filters = ensureArray(description.target.entity ?? []);
const primaryEntitiesOnly =
description.target.primary_entities_only ?? true;
return entityIds.some(
(entityId) =>
// Entities with a category only count when targeted directly,
// unless the component allows them
(!primaryEntitiesOnly ||
targetedIds.includes(entityId) ||
!hass.entities[entityId]?.entity_category) &&
(!filters.length ||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
);
})
.map(([key]) => key);
};
const mockForTarget = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
descriptions as TargetDescriptions
)
);
const mockPlatformSubscription = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
onChange?.(descriptions);
return () => undefined;
}
);
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
mockPlatformSubscription(
hass,
"trigger_platforms/subscribe",
triggerDescriptions
);
mockPlatformSubscription(
hass,
"condition_platforms/subscribe",
conditionDescriptions
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
hass.mockWS(
"get_services_for_target",
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
// Keyed by <domain>.<service>, like the triggers and conditions
Object.fromEntries(
Object.entries(currentHass.services).flatMap(([domain, services]) =>
Object.entries(services).map(([service, description]) => [
`${domain}.${service}`,
description,
])
)
) as TargetDescriptions
)
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,495 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
export const triggerIcons = {
air_quality: {
co2_changed: { trigger: "mdi:molecule-co2" },
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
co_changed: { trigger: "mdi:molecule-co" },
co_cleared: { trigger: "mdi:check-circle" },
co_crossed_threshold: { trigger: "mdi:molecule-co" },
co_detected: { trigger: "mdi:molecule-co" },
gas_cleared: { trigger: "mdi:check-circle" },
gas_detected: { trigger: "mdi:gas-cylinder" },
n2o_changed: { trigger: "mdi:factory" },
n2o_crossed_threshold: { trigger: "mdi:factory" },
no2_changed: { trigger: "mdi:factory" },
no2_crossed_threshold: { trigger: "mdi:factory" },
no_changed: { trigger: "mdi:factory" },
no_crossed_threshold: { trigger: "mdi:factory" },
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
pm10_changed: { trigger: "mdi:blur" },
pm10_crossed_threshold: { trigger: "mdi:blur" },
pm1_changed: { trigger: "mdi:blur" },
pm1_crossed_threshold: { trigger: "mdi:blur" },
pm25_changed: { trigger: "mdi:blur" },
pm25_crossed_threshold: { trigger: "mdi:blur" },
pm4_changed: { trigger: "mdi:blur" },
pm4_crossed_threshold: { trigger: "mdi:blur" },
smoke_cleared: { trigger: "mdi:check-circle" },
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
so2_changed: { trigger: "mdi:factory" },
so2_crossed_threshold: { trigger: "mdi:factory" },
voc_changed: { trigger: "mdi:air-filter" },
voc_crossed_threshold: { trigger: "mdi:air-filter" },
voc_ratio_changed: { trigger: "mdi:air-filter" },
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
},
alarm_control_panel: {
armed: { trigger: "mdi:shield" },
armed_away: { trigger: "mdi:shield-lock" },
armed_home: { trigger: "mdi:shield-home" },
armed_night: { trigger: "mdi:shield-moon" },
armed_vacation: { trigger: "mdi:shield-airplane" },
disarmed: { trigger: "mdi:shield-off" },
triggered: { trigger: "mdi:bell-ring" },
},
assist_satellite: {
idle: { trigger: "mdi:chat-sleep" },
listening: { trigger: "mdi:chat-question" },
processing: { trigger: "mdi:chat-processing" },
responding: { trigger: "mdi:chat-alert" },
},
battery: {
became_low: { trigger: "mdi:battery-alert" },
level_changed: { trigger: "mdi:battery-unknown" },
level_crossed_threshold: { trigger: "mdi:battery-alert" },
no_longer_low: { trigger: "mdi:battery" },
started_charging: { trigger: "mdi:battery-charging" },
stopped_charging: { trigger: "mdi:battery" },
},
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
calendar: {
event_ended: { trigger: "mdi:calendar-end" },
event_started: { trigger: "mdi:calendar-start" },
},
climate: {
hvac_mode_changed: { trigger: "mdi:thermostat" },
started_cooling: { trigger: "mdi:snowflake" },
started_drying: { trigger: "mdi:water-percent" },
started_heating: { trigger: "mdi:fire" },
target_humidity_changed: { trigger: "mdi:water-percent" },
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:power-off" },
turned_on: { trigger: "mdi:power-on" },
},
counter: {
decremented: { trigger: "mdi:numeric-negative-1" },
incremented: { trigger: "mdi:numeric-positive-1" },
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
reset: { trigger: "mdi:refresh" },
},
cover: {
awning_closed: { trigger: "mdi:storefront-outline" },
awning_opened: { trigger: "mdi:storefront-outline" },
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
blind_opened: { trigger: "mdi:blinds-horizontal" },
curtain_closed: { trigger: "mdi:curtains-closed" },
curtain_opened: { trigger: "mdi:curtains" },
shade_closed: { trigger: "mdi:roller-shade-closed" },
shade_opened: { trigger: "mdi:roller-shade" },
shutter_closed: { trigger: "mdi:window-shutter" },
shutter_opened: { trigger: "mdi:window-shutter-open" },
},
door: {
closed: { trigger: "mdi:door-closed" },
opened: { trigger: "mdi:door-open" },
},
doorbell: { rang: { trigger: "mdi:doorbell" } },
event: { received: { trigger: "mdi:eye-check" } },
fan: {
turned_off: { trigger: "mdi:fan-off" },
turned_on: { trigger: "mdi:fan" },
},
garage_door: {
closed: { trigger: "mdi:garage" },
opened: { trigger: "mdi:garage-open" },
},
gate: {
closed: { trigger: "mdi:gate" },
opened: { trigger: "mdi:gate-open" },
},
humidifier: {
mode_changed: { trigger: "mdi:air-humidifier" },
started_drying: { trigger: "mdi:arrow-down-bold" },
started_humidifying: { trigger: "mdi:arrow-up-bold" },
turned_off: { trigger: "mdi:air-humidifier-off" },
turned_on: { trigger: "mdi:air-humidifier" },
},
humidity: {
changed: { trigger: "mdi:water-percent" },
crossed_threshold: { trigger: "mdi:water-percent" },
},
illuminance: {
changed: { trigger: "mdi:brightness-6" },
cleared: { trigger: "mdi:brightness-4" },
crossed_threshold: { trigger: "mdi:brightness-6" },
detected: { trigger: "mdi:brightness-7" },
},
lawn_mower: {
became_idle: { trigger: "mdi:stop" },
errored: { trigger: "mdi:alert-circle-outline" },
paused_mowing: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-import-outline" },
started_mowing: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
light: {
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
turned_off: { trigger: "mdi:lightbulb-off" },
turned_on: { trigger: "mdi:lightbulb-on" },
},
lock: {
jammed: { trigger: "mdi:lock-alert" },
locked: { trigger: "mdi:lock" },
opened: { trigger: "mdi:lock-open-variant" },
unlocked: { trigger: "mdi:lock-open-variant" },
},
media_player: {
muted: { trigger: "mdi:volume-mute" },
paused_playing: { trigger: "mdi:pause" },
started_playing: { trigger: "mdi:play" },
stopped_playing: { trigger: "mdi:stop" },
turned_off: { trigger: "mdi:power" },
turned_on: { trigger: "mdi:power" },
unmuted: { trigger: "mdi:volume-high" },
volume_changed: { trigger: "mdi:volume-medium" },
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
},
moisture: {
changed: { trigger: "mdi:water-percent" },
cleared: { trigger: "mdi:water-off" },
crossed_threshold: { trigger: "mdi:water-percent" },
detected: { trigger: "mdi:water" },
},
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
motion: {
cleared: { trigger: "mdi:motion-sensor-off" },
detected: { trigger: "mdi:motion-sensor" },
},
occupancy: {
cleared: { trigger: "mdi:home-outline" },
detected: { trigger: "mdi:home-account" },
},
power: {
changed: { trigger: "mdi:flash" },
crossed_threshold: { trigger: "mdi:flash" },
},
remote: {
turned_off: { trigger: "mdi:remote-off" },
turned_on: { trigger: "mdi:remote" },
},
scene: { activated: { trigger: "mdi:palette" } },
schedule: {
block_ended: { trigger: "mdi:calendar-blank" },
block_started: { trigger: "mdi:calendar-clock" },
},
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
siren: {
turned_off: { trigger: "mdi:bullhorn-outline" },
turned_on: { trigger: "mdi:bullhorn" },
},
sun: {
blue_hour_ended: { trigger: "mdi:weather-sunset" },
blue_hour_started: { trigger: "mdi:weather-sunset" },
dawn: { trigger: "mdi:weather-sunset-up" },
dusk: { trigger: "mdi:weather-sunset-down" },
elevation_changed: { trigger: "mdi:sun-angle" },
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
golden_hour_ended: { trigger: "mdi:weather-sunset" },
golden_hour_started: { trigger: "mdi:weather-sunset" },
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
midnight_sun_started: { trigger: "mdi:weather-sunny" },
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
polar_night_started: { trigger: "mdi:weather-night" },
solar_midnight: { trigger: "mdi:weather-night" },
solar_noon: { trigger: "mdi:weather-sunny" },
sunrise: { trigger: "mdi:weather-sunset-up" },
sunset: { trigger: "mdi:weather-sunset-down" },
},
switch: {
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
turned_on: { trigger: "mdi:toggle-switch-variant" },
},
temperature: {
changed: { trigger: "mdi:thermometer" },
crossed_threshold: { trigger: "mdi:thermometer" },
},
text: { changed: { trigger: "mdi:form-textbox" } },
timer: {
cancelled: { trigger: "mdi:timer-cancel" },
finished: { trigger: "mdi:timer-check" },
paused: { trigger: "mdi:timer-pause" },
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
restarted: { trigger: "mdi:timer-refresh" },
started: { trigger: "mdi:timer-play" },
},
todo: {
item_added: { trigger: "mdi:clipboard-plus" },
item_completed: { trigger: "mdi:clipboard-check" },
item_removed: { trigger: "mdi:clipboard-minus" },
},
update: { became_available: { trigger: "mdi:package-up" } },
vacuum: {
errored: { trigger: "mdi:alert-circle-outline" },
paused_cleaning: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-outline" },
started_cleaning: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
valve: {
closed: { trigger: "mdi:valve-closed" },
opened: { trigger: "mdi:valve-open" },
},
vibration: {
cleared: { trigger: "mdi:vibrate-off" },
detected: { trigger: "mdi:vibrate" },
},
water_heater: {
operation_mode_changed: { trigger: "mdi:water-boiler" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:water-boiler-off" },
turned_on: { trigger: "mdi:water-boiler" },
},
window: {
closed: { trigger: "mdi:window-closed" },
opened: { trigger: "mdi:window-open" },
},
zone: {
entered: { trigger: "mdi:map-marker-plus" },
left: { trigger: "mdi:map-marker-minus" },
occupancy_cleared: { trigger: "mdi:account-off" },
occupancy_detected: { trigger: "mdi:account-group" },
},
};
export const conditionIcons = {
air_quality: {
is_co2_value: { condition: "mdi:molecule-co2" },
is_co_cleared: { condition: "mdi:check-circle" },
is_co_detected: { condition: "mdi:molecule-co" },
is_co_value: { condition: "mdi:molecule-co" },
is_gas_cleared: { condition: "mdi:check-circle" },
is_gas_detected: { condition: "mdi:gas-cylinder" },
is_n2o_value: { condition: "mdi:factory" },
is_no2_value: { condition: "mdi:factory" },
is_no_value: { condition: "mdi:factory" },
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
is_pm10_value: { condition: "mdi:blur" },
is_pm1_value: { condition: "mdi:blur" },
is_pm25_value: { condition: "mdi:blur" },
is_pm4_value: { condition: "mdi:blur" },
is_smoke_cleared: { condition: "mdi:check-circle" },
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
is_so2_value: { condition: "mdi:factory" },
is_voc_ratio_value: { condition: "mdi:air-filter" },
is_voc_value: { condition: "mdi:air-filter" },
},
alarm_control_panel: {
is_armed: { condition: "mdi:shield" },
is_armed_away: { condition: "mdi:shield-lock" },
is_armed_home: { condition: "mdi:shield-home" },
is_armed_night: { condition: "mdi:shield-moon" },
is_armed_vacation: { condition: "mdi:shield-airplane" },
is_disarmed: { condition: "mdi:shield-off" },
is_triggered: { condition: "mdi:bell-ring" },
},
assist_satellite: {
is_idle: { condition: "mdi:chat-sleep" },
is_listening: { condition: "mdi:chat-question" },
is_processing: { condition: "mdi:chat-processing" },
is_responding: { condition: "mdi:chat-alert" },
},
battery: {
is_charging: { condition: "mdi:battery-charging" },
is_level: { condition: "mdi:battery-unknown" },
is_low: { condition: "mdi:battery-alert" },
is_not_charging: { condition: "mdi:battery" },
is_not_low: { condition: "mdi:battery" },
},
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
climate: {
is_cooling: { condition: "mdi:snowflake" },
is_drying: { condition: "mdi:water-percent" },
is_heating: { condition: "mdi:fire" },
is_hvac_mode: { condition: "mdi:thermostat" },
is_off: { condition: "mdi:power-off" },
is_on: { condition: "mdi:power-on" },
is_target_humidity: { condition: "mdi:water-percent" },
is_target_temperature: { condition: "mdi:thermometer" },
},
counter: { is_value: { condition: "mdi:counter" } },
cover: {
awning_is_closed: { condition: "mdi:storefront-outline" },
awning_is_open: { condition: "mdi:storefront-outline" },
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
blind_is_open: { condition: "mdi:blinds-horizontal" },
curtain_is_closed: { condition: "mdi:curtains-closed" },
curtain_is_open: { condition: "mdi:curtains" },
shade_is_closed: { condition: "mdi:roller-shade-closed" },
shade_is_open: { condition: "mdi:roller-shade" },
shutter_is_closed: { condition: "mdi:window-shutter" },
shutter_is_open: { condition: "mdi:window-shutter-open" },
},
door: {
is_closed: { condition: "mdi:door-closed" },
is_open: { condition: "mdi:door-open" },
},
fan: {
is_off: { condition: "mdi:fan-off" },
is_on: { condition: "mdi:fan" },
},
garage_door: {
is_closed: { condition: "mdi:garage" },
is_open: { condition: "mdi:garage-open" },
},
gate: {
is_closed: { condition: "mdi:gate" },
is_open: { condition: "mdi:gate-open" },
},
humidifier: {
is_drying: { condition: "mdi:arrow-down-bold" },
is_humidifying: { condition: "mdi:arrow-up-bold" },
is_mode: { condition: "mdi:air-humidifier" },
is_off: { condition: "mdi:air-humidifier-off" },
is_on: { condition: "mdi:air-humidifier" },
is_target_humidity: { condition: "mdi:water-percent" },
},
humidity: { is_value: { condition: "mdi:water-percent" } },
illuminance: {
is_detected: { condition: "mdi:brightness-7" },
is_not_detected: { condition: "mdi:brightness-4" },
is_value: { condition: "mdi:brightness-percent" },
},
lawn_mower: {
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_idle: { condition: "mdi:stop" },
is_mowing: { condition: "mdi:play" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
light: {
is_brightness: { condition: "mdi:lightbulb-on-50" },
is_off: { condition: "mdi:lightbulb-off" },
is_on: { condition: "mdi:lightbulb-on" },
},
lock: {
is_jammed: { condition: "mdi:lock-alert" },
is_locked: { condition: "mdi:lock" },
is_open: { condition: "mdi:lock-open-variant" },
is_unlocked: { condition: "mdi:lock-open-variant" },
},
media_player: {
is_muted: { condition: "mdi:volume-mute" },
is_not_playing: { condition: "mdi:stop" },
is_off: { condition: "mdi:cast-off" },
is_on: { condition: "mdi:cast" },
is_paused: { condition: "mdi:pause" },
is_playing: { condition: "mdi:play" },
is_unmuted: { condition: "mdi:volume-high" },
is_volume: { condition: "mdi:volume-medium" },
},
moisture: {
is_detected: { condition: "mdi:water" },
is_not_detected: { condition: "mdi:water-off" },
is_value: { condition: "mdi:water-percent" },
},
moon: {
is_phase: { condition: "mdi:moon-full" },
is_waning: { condition: "mdi:moon-waning-crescent" },
is_waxing: { condition: "mdi:moon-waxing-crescent" },
},
motion: {
is_detected: { condition: "mdi:motion-sensor" },
is_not_detected: { condition: "mdi:motion-sensor-off" },
},
occupancy: {
is_detected: { condition: "mdi:home-account" },
is_not_detected: { condition: "mdi:home-outline" },
},
power: { is_value: { condition: "mdi:flash" } },
remote: {
is_off: { condition: "mdi:remote-off" },
is_on: { condition: "mdi:remote" },
},
schedule: {
is_off: { condition: "mdi:calendar-blank" },
is_on: { condition: "mdi:calendar-clock" },
},
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
siren: {
is_off: { condition: "mdi:bullhorn-outline" },
is_on: { condition: "mdi:bullhorn" },
},
sun: {
elevation: { condition: "mdi:sun-angle" },
is_ascending: { condition: "mdi:weather-sunset-up" },
is_blue_hour: { condition: "mdi:weather-sunset" },
is_descending: { condition: "mdi:weather-sunset-down" },
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
is_golden_hour: { condition: "mdi:weather-sunset" },
is_midnight_sun: { condition: "mdi:weather-sunny" },
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
is_night: { condition: "mdi:weather-night" },
is_polar_night: { condition: "mdi:weather-night" },
is_set: { condition: "mdi:weather-sunny-off" },
is_up: { condition: "mdi:weather-sunny" },
},
switch: {
is_off: { condition: "mdi:toggle-switch-variant-off" },
is_on: { condition: "mdi:toggle-switch-variant" },
},
temperature: { is_value: { condition: "mdi:thermometer" } },
text: { is_equal_to: { condition: "mdi:form-textbox" } },
timer: {
is_active: { condition: "mdi:timer" },
is_idle: { condition: "mdi:timer-off" },
is_paused: { condition: "mdi:timer-pause" },
},
todo: {
all_completed: { condition: "mdi:clipboard-check" },
incomplete: { condition: "mdi:clipboard-alert" },
},
update: {
is_available: { condition: "mdi:package-up" },
is_not_available: { condition: "mdi:package" },
},
vacuum: {
is_cleaning: { condition: "mdi:play" },
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
valve: {
is_closed: { condition: "mdi:valve-closed" },
is_open: { condition: "mdi:valve-open" },
},
vibration: {
is_detected: { condition: "mdi:vibrate" },
is_not_detected: { condition: "mdi:vibrate-off" },
},
water_heater: {
is_off: { condition: "mdi:water-boiler-off" },
is_on: { condition: "mdi:water-boiler" },
is_operation_mode: { condition: "mdi:water-boiler" },
is_target_temperature: { condition: "mdi:thermometer" },
},
window: {
is_closed: { condition: "mdi:window-closed" },
is_open: { condition: "mdi:window-open" },
},
zone: {
in_zone: { condition: "mdi:map-marker-check" },
not_in_zone: { condition: "mdi:map-marker-remove" },
occupancy_is_detected: { condition: "mdi:account-group" },
occupancy_is_not_detected: { condition: "mdi:account-off" },
},
};
-50
View File
@@ -1,53 +1,3 @@
export const mapEntities = () =>
Object.values({
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
hidden: true,
latitude: 52.3631339,
longitude: 4.8903147,
radius: 200,
friendly_name: "Home",
icon: "hademo:home",
},
},
"zone.uva": {
entity_id: "zone.buckhead",
state: "zoning",
attributes: {
hidden: true,
radius: 400,
friendly_name: "UvA",
icon: "hademo:school",
latitude: 52.3558182,
longitude: 4.9535376,
},
},
"person.arsaboo": {
entity_id: "person.arsaboo",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Arsaboo",
latitude: 52.3579946,
longitude: 4.8664597,
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
},
},
"person.melody": {
entity_id: "person.melody",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Melody",
latitude: 52.3408927,
longitude: 4.8711073,
entity_picture: "/assets/arsaboo/images/melody.jpg",
},
},
});
export const energyEntities = () =>
Object.values({
"sensor.grid_fossil_fuel_percentage": {
+222 -3
View File
@@ -1,25 +1,244 @@
import type {
EntityRegistryDisplayEntryResponse,
EntityRegistryEntry,
ExtEntityRegistryEntry,
UpdateEntityRegistryEntryResult,
} from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
return {
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
id: entry.entity_id,
name: null,
icon: null,
labels: [],
categories: {},
hidden_by: null,
entity_category: null,
has_entity_name: false,
options: null,
created_at: now,
modified_at: now,
...entry,
};
};
// The registry settings that the display registry holds. A display entry
// without a registry name shows the name of its state.
const displayFields = (entry: EntityRegistryEntry) => ({
...(entry.name && { name: entry.name }),
icon: entry.icon ?? undefined,
area_id: entry.area_id ?? undefined,
labels: entry.labels,
hidden: entry.hidden_by !== null,
});
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
});
export const mockEntityRegistry = (
hass: MockHomeAssistant,
data: EntityRegistryEntry[] = []
) => {
hass.mockWS("config/entity_registry/list", () => data);
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
const result: Record<string, ExtEntityRegistryEntry> = {};
for (const entityId of msg.entity_ids) {
const entry = data.find((e) => e.entity_id === entityId);
const entry = entries.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
result[entityId] = extEntry(entry);
}
}
return result;
}
);
hass.mockWS(
"config/entity_registry/update",
(
msg: Pick<
EntityRegistryEntry,
| "entity_id"
| "name"
| "icon"
| "area_id"
| "labels"
| "categories"
| "hidden_by"
| "disabled_by"
> & { new_entity_id?: string },
currentHass: MockHomeAssistant
): UpdateEntityRegistryEntryResult => {
const {
entity_id: entityId,
new_entity_id: newEntityId,
...updates
} = msg;
// Renaming would have to move the entity in every mock, so refuse it
// instead of reporting a rename that did not happen
if (newEntityId && newEntityId !== entityId) {
throw new Error("Changing the entity ID is not supported in the demo");
}
// The demo entities without an entry get one, like any core entity has
const entry = {
...(entries.find((e) => e.entity_id === entityId) ??
newEntry({
entity_id: entityId,
platform: currentHass.entities[entityId]?.platform ?? "demo",
unique_id: entityId,
})),
...Object.fromEntries(
Object.entries(updates).filter(([key]) => key !== "type")
),
modified_at: Date.now() / 1000,
};
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
// Core updates the display registry too, only change what was updated
const display = currentHass.entities[entityId];
if (display) {
const fields = displayFields(entry);
currentHass.updateHass({
entities: {
...currentHass.entities,
[entityId]: {
...display,
...("name" in updates && { name: fields.name }),
...("icon" in updates && { icon: fields.icon }),
...("area_id" in updates && { area_id: fields.area_id }),
...("labels" in updates && { labels: fields.labels }),
...("hidden_by" in updates && { hidden: fields.hidden }),
},
},
});
}
currentHass.mockEvent("entity_registry_updated");
return { entity_entry: extEntry(entry) };
}
);
};
// Entries of a platform that were replaced, so their settings are restored
// when the entity comes back after switching demos
const retiredEntries = new Map<string, EntityRegistryEntry>();
// Replaces the entries of a platform, like core does for the entities of a
// demo config
export const setPlatformEntityRegistryEntries = (
hass: MockHomeAssistant,
platform: string,
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
) => {
for (const entry of entries) {
if (entry.platform === platform) {
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
}
}
const restored: EntityRegistryEntry[] = [];
const platformRegistryEntries = platformEntries.map((entry) => {
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
if (!retired) {
return newEntry({ ...entry, platform });
}
const restoredEntry = { ...retired, entity_id: entry.entity_id };
restored.push(restoredEntry);
return restoredEntry;
});
entries = [
...entries.filter((e) => e.platform !== platform),
...platformRegistryEntries,
];
// The display registry was rebuilt with the entities, restore its settings
if (restored.length) {
const entities = { ...hass.entities };
for (const entry of restored) {
if (entities[entry.entity_id]) {
entities[entry.entity_id] = {
...entities[entry.entity_id],
...displayFields(entry),
};
}
}
hass.updateHass({ entities });
}
hass.mockEvent("entity_registry_updated");
};
export const getEntityRegistryEntry = (entityId: string) =>
entries.find((e) => e.entity_id === entityId);
export const removeEntityRegistryEntry = (
hass: MockHomeAssistant,
entityId: string
) => {
entries = entries.filter((e) => e.entity_id !== entityId);
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
) => {
entries = [
...entries.filter((e) => e.entity_id !== entry.entity_id),
newEntry(entry),
];
hass.mockEvent("entity_registry_updated");
};
// The app refetches the display registry on entity_registry_updated events.
// Answer with the current display registry, which the demo keeps in hass.
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
hass.mockWS(
"config/entity_registry/list_for_display",
(
_msg,
currentHass: MockHomeAssistant
): EntityRegistryDisplayEntryResponse => {
const categories = [
...new Set(
Object.values(currentHass.entities)
.map((entity) => entity.entity_category)
.filter((category) => category !== undefined)
),
];
return {
entities: Object.values(currentHass.entities).map((entity) => ({
ei: entity.entity_id,
di: entity.device_id,
ai: entity.area_id,
np: entity.next_name_part,
lb: entity.labels,
ec: entity.entity_category
? categories.indexOf(entity.entity_category)
: undefined,
en: entity.name,
ic: entity.icon,
pl: entity.platform ?? "demo",
tk: entity.translation_key,
hb: entity.hidden,
dp: entity.display_precision,
hn: entity.has_entity_name,
})),
entity_categories: Object.fromEntries(
categories.map((category, index) => [index, category])
),
};
}
);
+11
View File
@@ -12,6 +12,17 @@ export const mockIcons = (hass: MockHomeAssistant) => {
category: IconCategory;
integration?: string;
}) => {
if (category === "triggers" || category === "conditions") {
const { triggerIcons, conditionIcons } =
await import("./automation_platforms_icons");
const icons: Record<string, unknown> =
category === "triggers" ? triggerIcons : conditionIcons;
return {
resources: integration
? { [integration]: icons[integration] ?? {} }
: icons,
};
}
if (integration) {
try {
const response = await fetch(
+8 -1
View File
@@ -1,7 +1,14 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let labels: LabelRegistryEntry[] = [];
export const getLabelIds = () => labels.map((label) => label.label_id);
export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => hass.mockWS("config/label_registry/list", () => data);
) => {
labels = data;
hass.mockWS("config/label_registry/list", () => data);
};
+2 -2
View File
@@ -9,7 +9,7 @@ import {
import "../custom-cards/cast-demo-row";
import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mapEntities } from "./entities";
import { mockMap } from "./map";
export const mockLovelace = (
hass: MockHomeAssistant,
@@ -17,7 +17,7 @@ export const mockLovelace = (
) => {
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
hass.addEntities(mapEntities());
mockMap(hass);
return {
strategy: {
type: "map",
+243
View File
@@ -0,0 +1,243 @@
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityHistoryState } from "../../../src/data/history";
import type { Zone } from "../../../src/data/zone";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600 * 1000;
type ZoneData = Omit<Zone, "id">;
const HOME: ZoneData = {
name: "Home",
icon: "mdi:home",
latitude: demoConfig.latitude,
longitude: demoConfig.longitude,
radius: 100,
passive: false,
};
export const zones: Zone[] = [
{
id: "gym",
name: "Gym",
icon: "mdi:dumbbell",
latitude: 52.358,
longitude: 4.8686,
radius: 150,
passive: false,
},
{
id: "school",
name: "School",
icon: "mdi:school",
latitude: 52.3558182,
longitude: 4.9535376,
radius: 250,
passive: false,
},
{
id: "supermarket",
name: "Supermarket",
icon: "mdi:cart",
latitude: 52.3793,
longitude: 4.8994,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
interface DemoPerson {
id: string;
name: string;
picture: string;
latitude: number;
longitude: number;
// Hours ago and the state entered then, oldest first. The first entry is
// the state before the 24 hour activity window, the last one the current
// state.
timeline: [number, string][];
}
const PEOPLE: DemoPerson[] = [
{
id: "arsaboo",
name: "Arsaboo",
picture: "/assets/arsaboo/images/arsaboo.jpg",
latitude: 52.3732,
longitude: 4.8904,
timeline: [
[30, "home"],
[10, "not_home"],
[9.5, "Work"],
[5, "not_home"],
[4.6, "Gym"],
[3.4, "not_home"],
[3.1, "Supermarket"],
[2.8, "not_home"],
[2.4, "home"],
],
},
{
id: "melody",
name: "Melody",
picture: "/assets/arsaboo/images/melody.jpg",
latitude: 52.373,
longitude: 4.8901,
timeline: [
[30, "home"],
[9, "not_home"],
[8.6, "School"],
[4, "not_home"],
[3.6, "home"],
],
},
{
id: "oscar",
name: "Oscar",
picture: "/assets/kernehed/oscar.jpg",
latitude: 52.391,
longitude: 4.8532,
timeline: [
[30, "home"],
[9, "not_home"],
[8.7, "Work"],
[4.5, "not_home"],
[4.2, "Supermarket"],
[3.8, "not_home"],
[3.5, "Work"],
],
},
{
id: "bella",
name: "Bella",
picture: "/assets/kernehed/bella.jpg",
latitude: 52.365,
longitude: 4.8795,
timeline: [
[30, "home"],
[8, "not_home"],
[7.6, "Work"],
[1.2, "not_home"],
[0.9, "Gym"],
[0.3, "not_home"],
],
},
];
const CAR: EntityInput = {
entity_id: "device_tracker.car",
state: "not_home",
attributes: {
friendly_name: "Car",
icon: "mdi:car",
source_type: "gps",
latitude: 52.3805,
longitude: 4.8718,
gps_accuracy: 10,
},
};
const lastMove = (person: DemoPerson) =>
person.timeline[person.timeline.length - 1];
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
// A person's state is "home" for the home zone, the zone name otherwise
const personState = entityId === "zone.home" ? "home" : zone.name;
const persons = PEOPLE.filter(
(person) => lastMove(person)[1] === personState
).map((person) => `person.${person.id}`);
return {
entity_id: entityId,
state: String(persons.length),
attributes: {
latitude: zone.latitude,
longitude: zone.longitude,
radius: zone.radius,
passive: zone.passive,
persons,
editable: true,
icon: zone.icon,
friendly_name: zone.name,
},
};
};
export const zoneEntities = (): EntityInput[] => [
zoneEntity("zone.home", HOME),
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
];
// UI zones are in the entity registry, so the zone editor does not also list
// them as YAML zones
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
entity_id: `zone.${zone.id}`,
id: `zone.${zone.id}`,
name: null,
icon: null,
labels: [],
categories: {},
platform: "zone",
hidden_by: null,
entity_category: null,
has_entity_name: false,
unique_id: zone.id,
options: null,
created_at: 0,
modified_at: 0,
}));
const personEntity = (person: DemoPerson, now: number): EntityInput => {
const [hoursAgo, state] = lastMove(person);
return {
entity_id: `person.${person.id}`,
state,
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
attributes: {
friendly_name: person.name,
entity_picture: person.picture,
latitude: person.latitude,
longitude: person.longitude,
gps_accuracy: 20,
},
};
};
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
const now = Date.now();
hass.addEntities([
...zoneEntities(),
...PEOPLE.map((person) => personEntity(person, now)),
CAR,
]);
hass.mockWS(
"history/history_during_period",
({ entity_ids }: { entity_ids?: string[] }) =>
Object.fromEntries(
PEOPLE.filter(
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
).map((person) => [`person.${person.id}`, personHistory(person, now)])
)
);
};
+2 -22
View File
@@ -1,27 +1,7 @@
import type { Zone } from "../../../src/data/zone";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const zones: Zone[] = [
{
id: "home",
name: "Home",
icon: "mdi:home",
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
import { zoneEntities, zones } from "./map";
export const mockZone = (hass: MockHomeAssistant) => {
hass.mockWS("zone/list", () => zones);
hass.addEntities(zoneEntities());
};
+1
View File
@@ -101,6 +101,7 @@ export default [
"entity-button-card",
"entity-filter-card",
"glance-card",
"sensor-card",
"tile-card",
"area-card",
],
@@ -0,0 +1,3 @@
---
title: Sensor card
---
+147
View File
@@ -0,0 +1,147 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { SensorCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
const ENTITIES = [
{
entity_id: "sensor.outside_temperature",
state: "15.6",
attributes: {
friendly_name: "Outside temperature",
device_class: "temperature",
unit_of_measurement: "°C",
},
},
{
entity_id: "sensor.outside_humidity",
state: "54",
attributes: {
friendly_name: "Outside humidity",
device_class: "humidity",
unit_of_measurement: "%",
},
},
{
entity_id: "sensor.phone_battery",
state: "18",
attributes: {
friendly_name: "Phone battery",
device_class: "battery",
unit_of_measurement: "%",
},
},
{
entity_id: "sensor.not_working",
state: "unavailable",
attributes: {
friendly_name: "Not working",
},
},
];
const CONFIGS = [
{
heading: "Basic example",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
},
},
{
heading: "With line graph",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
graph: "line",
},
},
{
heading: "With graph detail and hours to show",
config: {
type: "sensor",
entity: "sensor.outside_humidity",
graph: "line",
detail: 2,
hours_to_show: 6,
},
},
{
heading: "With state color",
config: {
type: "sensor",
entity: "sensor.phone_battery",
icon: "mdi:battery-20",
graph: "line",
state_color: true,
},
},
{
heading: "With name, icon and unit",
config: {
type: "sensor",
entity: "sensor.outside_humidity",
name: "Humidity",
icon: "mdi:water",
unit: "pct",
},
},
{
heading: "Without tap action (not focusable)",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
tap_action: { action: "none" },
},
},
{
heading: "Unavailable",
config: {
type: "sensor",
entity: "sensor.not_working",
},
},
{
heading: "Invalid entity",
config: {
type: "sensor",
entity: "sensor.invalid_entity",
},
},
] satisfies DemoCardConfig<SensorCardConfig>[];
@customElement("demo-lovelace-sensor-card")
class DemoSensorCard extends LitElement {
@query("#demos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "history"],
},
});
hass.addEntities(ENTITIES);
mockIcons(hass);
mockHistory(hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-lovelace-sensor-card": DemoSensorCard;
}
}
+1 -1
View File
@@ -106,7 +106,7 @@
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.0",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.2",
"js-yaml": "5.4.2",
+5 -5
View File
@@ -226,8 +226,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.7.0
version: 9.7.0
specifier: 9.7.1
version: 9.7.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -5054,8 +5054,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
[email protected].0:
resolution: {integrity: sha512-AE/VtwY+sKi7B3934KuIgvj2g06QY9aFVdd2zh1r5ygbpMo2TZbvN9Zoma8L2wHNYFsngTRiZxYOnLlCysIOzA==}
[email protected].1:
resolution: {integrity: sha512-zOAiyF+gUc/IsiswYdWKns6SCBbvWb8iJtMX9+Dk0wlbdfcp+I38T2Vx36krWeWMk/GPwoSiYwCs5hk/+Hsn7w==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -12770,7 +12770,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
+3
View File
@@ -3,6 +3,9 @@ autoInstallPeers: false
# Supply-chain protection
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeExclude:
# Our own packages, published from our own CI
- home-assistant-js-websocket
trustPolicy: no-downgrade
trustPolicyExclude:
# Backports published after provenance-signed 7.x releases
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260930.0"
version = "20260930.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-demo-core-data
+11
View File
@@ -39,3 +39,14 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
}
return `#${color}`;
};
/**
* Converts a CSS color, like a color name, to `#rrggbb`.
* @param color - Any CSS color (name, hex, rgb(), hsl() and so on).
* @returns The hex color, or undefined for invalid colors and colors with
* transparency.
*/
export const cssColorToHex = (color: string): string | undefined => {
const parsed = parse(color.trim());
return parsed && (parsed.alpha ?? 1) === 1 ? formatHex(parsed) : undefined;
};
@@ -66,7 +66,7 @@ export class ScrollFadeController {
if (!this._element) {
return;
}
const rtl = this._element.matches(":dir(rtl)");
const rtl = getComputedStyle(this._element).direction === "rtl";
const scrolled = rtl ? -this._element.scrollLeft : this._element.scrollLeft;
const maxScroll = this._element.scrollWidth - this._element.clientWidth;
const start = scrolled > 1;
+39
View File
@@ -0,0 +1,39 @@
const SWIPE_LOCKED_COMPONENTS = new Set([
"ha-control-slider",
"ha-slider",
"ha-control-switch",
"ha-control-circular-slider",
"ha-hs-color-picker",
"ha-map",
"ha-more-info-control-select-container",
"ha-filter-chip",
]);
const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
/**
* Whether a touch inside a bottom sheet belongs to its content rather than to
* the sheet: it started in something scrolled away from its top, or in a
* control that drags on its own. Only the path up to the sheet is checked.
*/
export const isSwipeLocked = (
path: EventTarget[],
sheet: EventTarget
): boolean => {
for (const target of path) {
if (target === sheet) {
return false;
}
if (
target instanceof HTMLElement &&
(target.scrollTop > 0 ||
SWIPE_LOCKED_COMPONENTS.has(target.localName) ||
Array.from(target.classList).some((cls) =>
SWIPE_LOCKED_CLASSES.has(cls)
))
) {
return true;
}
}
return false;
};
@@ -17,7 +17,7 @@ import { capitalizeFirstLetter } from "../string/capitalize-first-letter";
import { isDate } from "../string/is_date";
import { isTimestamp } from "../string/is_timestamp";
import { blankBeforeUnit } from "../translations/blank_before_unit";
import type { LocalizeFunc } from "../translations/localize";
import type { LocalizeFunc, LocalizeKeys } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -198,6 +198,8 @@ export const computeAttributeNameDisplay = (
localize(
`component.${domain}.entity_component._.state_attributes.${attribute}.name`
) ||
// Attributes every entity can have, which no integration translates
localize(`ui.components.attributes.names.${attribute}` as LocalizeKeys) ||
capitalizeFirstLetter(
attribute
.replace(/_/g, " ")
+10 -3
View File
@@ -6,7 +6,10 @@ import type {
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
import {
isDeviceName,
stripPrefixFromEntityName,
} from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
@@ -48,8 +51,12 @@ export const computeEntityEntryName = (
const deviceName = computeDeviceName(device);
// An empty entity name or one equal to the device name means the entity uses the device name
if (!name || deviceName === name) {
if (entry.name == null || entry.next_name_part !== "device") {
return name || undefined;
}
// An empty entity name or one that is only the device name means the entity uses the device name
if (!name || (deviceName && isDeviceName(name, deviceName))) {
return undefined;
}
@@ -30,4 +30,19 @@ export const stripPrefixFromEntityName = (
return undefined;
};
/**
* Whether an entity name is only the device name, which core treats as no name of its own.
*/
export const isDeviceName = (entityName: string, deviceName: string) => {
if (!deviceName) {
return false;
}
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedDeviceName = deviceName.toLowerCase();
return (
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
);
};
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
+42 -6
View File
@@ -106,6 +106,22 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, location.href).href;
// Shared only while in flight, or a style that failed once would never be
// fetched again. Text rather than a parsed style: MapLibre mutates what it
// is handed.
const pendingStyles = new Map<string, Promise<string>>();
const fetchStyle = (url: string): Promise<string> => {
let pending = pendingStyles.get(url);
if (!pending) {
pending = fetch(url)
.then((response) => response.text())
.finally(() => pendingStyles.delete(url));
pendingStyles.set(url, pending);
}
return pending;
};
export const loadStyle = async (
mapStyle: ResolvedMapStyle
): Promise<StyleSpecification> => {
@@ -114,7 +130,7 @@ export const loadStyle = async (
// Anything that does not ship is built here, from the same builder: its own
// chunk, so a map on the default style never downloads it.
const style: StyleSpecification = shipped
? await (await fetch(shipped)).json()
? JSON.parse(await fetchStyle(shipped))
: await (
await import("./build-map-style")
).buildMapStyle(
@@ -204,6 +220,9 @@ const createVectorLayer = async (
let latestRequest = 0;
let vector = true;
let refused = false;
// A fallback hands this to the raster layer, and a rotation that already
// happened is not announced again.
let currentToken = token;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
@@ -224,7 +243,7 @@ const createVectorLayer = async (
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map, token);
createRasterLayer(leaflet, map, currentToken);
};
const scheduleSwap = () => {
@@ -291,7 +310,8 @@ const createVectorLayer = async (
// Only a new token clears the refusal. A style change in between applies a
// style that is refused just as the last one was, so it proves nothing.
const unsubscribeToken = subscribeMapTilesToken(() => {
const unsubscribeToken = subscribeMapTilesToken((newToken) => {
currentToken = newToken;
if (vector && refused) {
refused = false;
applyStyle(requestedStyle);
@@ -327,11 +347,27 @@ const createRasterLayer = (
} as TokenTileLayerOptions)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
// The only sign this path gets that its token is stale. Throttled, or a
// tile missing for another reason asks on every pan.
let refused = false;
let lastRecovery = 0;
layer.on("tileerror", () => {
refused = true;
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refreshMapTilesToken();
});
// Substituted per request, so later tiles pick up a new token by
// themselves. Only the ones already cached as failures need a redraw.
const unsubscribe = subscribeMapTilesToken((newToken) => {
(layer.options as TokenTileLayerOptions).token = newToken;
// Tiles that 403'd are cached as failures; only a redraw asks again.
layer.redraw();
if (refused) {
refused = false;
layer.redraw();
}
});
map.on("unload", unsubscribe);
+50 -7
View File
@@ -13,16 +13,18 @@ import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleHandle,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
MapPathHandle,
MapView,
} from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
import { setMarkerAccessibility } from "../marker-accessibility";
@@ -106,6 +108,9 @@ export class LeafletMapEngine implements MapEngine {
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
// Leaflet draws its container as soon as the layer is on it, and fills the
// tiles in over an opaque background from there
events.drawn?.();
}
public destroy(): void {
@@ -167,6 +172,14 @@ export class LeafletMapEngine implements MapEngine {
this.leafletMap?.setView(center, zoom);
}
public getView(): MapView | undefined {
if (!this.leafletMap) {
return undefined;
}
const center = this.leafletMap.getCenter();
return { center: [center.lat, center.lng], zoom: this._getZoom() };
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
@@ -185,7 +198,10 @@ export class LeafletMapEngine implements MapEngine {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
const fit = options?.fly
? this.leafletMap.flyToBounds
: this.leafletMap.fitBounds;
fit.call(this.leafletMap, bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
@@ -213,6 +229,7 @@ export class LeafletMapEngine implements MapEngine {
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
stroke: options.decoration.outline !== false,
radius: options.decoration.radius,
})
: undefined;
@@ -221,8 +238,12 @@ export class LeafletMapEngine implements MapEngine {
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
if (options.title && options.nativeTitle !== false) {
element.title = options.title;
}
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
zIndexOffset: options.raised ? 1000 : 0,
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
@@ -231,7 +252,6 @@ export class LeafletMapEngine implements MapEngine {
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
@@ -261,16 +281,39 @@ export class LeafletMapEngine implements MapEngine {
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
): MapCircleHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
stroke: options.outline !== false,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
return {
update: (newCenter, newOptions) => {
circle
.setLatLng(newCenter)
.setRadius(newOptions.radius)
.setStyle({
color: newOptions.color,
stroke: newOptions.outline !== false,
});
},
remove: () => circle.remove(),
};
}
public addPath(path: MapPath): MapItemHandle {
public addPath(path: MapPath): MapPathHandle {
let items = this._drawPath(path);
return {
update: (next) => {
items.forEach((item) => item.remove());
items = this._drawPath(next);
},
remove: () => items.forEach((item) => item.remove()),
};
}
private _drawPath(path: MapPath): (Polyline | CircleMarker)[] {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
@@ -293,7 +336,7 @@ export class LeafletMapEngine implements MapEngine {
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
return items;
}
public setClustering(options: MapClusterOptions | null): void {
+263 -83
View File
@@ -31,6 +31,7 @@ import {
import { deepEqual } from "../../util/deep-equal";
import { isTouch } from "../../../util/is_touch";
import type {
MapCircleHandle,
MapCircleOptions,
MapClusterIcon,
MapClusterOptions,
@@ -49,6 +50,8 @@ import type {
MapLatLng,
MapMarkerOptions,
MapPath,
MapPathHandle,
MapView,
} from "../map-engine";
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
@@ -86,6 +89,11 @@ const WHEEL_ZOOM_RATE = 1 / 200;
// Regroup clusters once continuous zooming settles, not on every wheel notch
export const CLUSTER_REBUILD_DELAY = 120;
const ATTRIBUTION_SHOW_DURATION = 5000;
const ATTRIBUTION_SHOWN_CLASS = "maplibregl-compact-show";
// The app's narrow-layout breakpoint (home-assistant-main)
const NARROW_VIEWPORT_QUERY = "(max-width: 870px)";
type GeoJSONSourceSpecification = Extract<
Parameters<MapLibreMap["addSource"]>[1],
{ type: "geojson" }
@@ -121,6 +129,42 @@ const circlePolygon = (
};
};
const coloredCircle = (
center: MapLatLng,
options: MapCircleOptions
): Feature<Polygon> => ({
...circlePolygon(center, options.radius),
properties: { color: options.color, outline: options.outline !== false },
});
const pathLines = (path: MapPath): FeatureCollection => ({
type: "FeatureCollection",
features: path.segments.map((segment) => ({
type: "Feature",
properties: { color: path.color, opacity: segment.opacity ?? 1 },
geometry: {
type: "LineString",
coordinates: segment.points.map((point) => [point[1], point[0]]),
},
})),
});
const pathPoints = (path: MapPath): FeatureCollection => ({
type: "FeatureCollection",
features: path.markers.map((pathMarker) => ({
type: "Feature",
properties: {
color: path.color,
opacity: pathMarker.opacity ?? 1,
tooltip: pathMarker.tooltipHtml,
},
geometry: {
type: "Point",
coordinates: [pathMarker.location[1], pathMarker.location[0]],
},
})),
});
interface ManagedMarker {
element: HTMLElement;
location: MapLatLng;
@@ -154,10 +198,15 @@ const iconFootprint = (icon: MapClusterIcon, center: MapLatLng): Footprint => ({
half: Math.max(...icon.size) / 2,
});
const markerFootprint = (managed: ManagedMarker): Footprint => ({
location: managed.location,
offset: [0, 0],
half: Math.max(...managed.options.size) / 2,
const markerFootprint = ({ location, options }: ManagedMarker): Footprint => ({
location,
offset: options.anchor
? [
options.size[0] / 2 - options.anchor[0],
options.size[1] / 2 - options.anchor[1],
]
: [0, 0],
half: Math.max(...options.size) / 2,
});
interface ClusterGroup {
@@ -194,6 +243,14 @@ export class MapLibreMapEngine implements MapEngine {
private _scaleControl?: IControl;
private _attributionControl?: IControl;
private _attributionObserver?: MutationObserver;
private _attributionTimeout?: number;
private _narrowViewport = window.matchMedia(NARROW_VIEWPORT_QUERY);
private _markers: ManagedMarker[] = [];
private _clusterOptions: MapClusterOptions | null = null;
@@ -296,8 +353,7 @@ export class MapLibreMapEngine implements MapEngine {
touchPitch: false,
// Rendered with a device font, so these glyphs are never requested
localIdeographFontFamily: "sans-serif",
// Inline on wide maps, collapsible (open by default) on narrow ones
attributionControl: {},
attributionControl: false,
// Proxied by core behind a token; absolute so the worker can resolve them
transformRequest: (url) => ({
...withMapTilesToken(url),
@@ -335,6 +391,8 @@ export class MapLibreMapEngine implements MapEngine {
this._zoomControl = new maplibre.NavigationControl({ showCompass: false });
map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]);
this._setUpAttribution();
this._narrowViewport.addEventListener("change", this._setUpAttribution);
map.on("click", (ev) => {
// Clicks on path points (they have tooltips) are not map clicks
@@ -345,6 +403,9 @@ export class MapLibreMapEngine implements MapEngine {
this._events.click?.([ev.lngLat.lat, ev.lngLat.lng]);
}
});
// Everything the first view needs has been drawn; before this the canvas
// can still be blank, whatever the style says
map.once("load", () => this._events.drawn?.());
map.on("zoomstart", () => this._events.zoomStart?.());
map.on("movestart", () => {
// resize() fires movestart even when nothing changed (see _resize)
@@ -395,6 +456,62 @@ export class MapLibreMapEngine implements MapEngine {
}
};
// Compact on a narrow viewport too, not only on a narrow map
private _setUpAttribution = (): void => {
const map = this._map;
if (!map || !this._maplibre) {
return;
}
if (this._attributionControl) {
map.removeControl(this._attributionControl);
}
this._attributionControl = new this._maplibre.AttributionControl({
compact: this._narrowViewport.matches ? true : undefined,
});
map.addControl(this._attributionControl, "bottom-right");
const attribution = map
.getContainer()
.querySelector<HTMLElement>(".maplibregl-ctrl-attrib");
if (attribution) {
this._autoHideAttribution(attribution);
}
};
private _autoHideAttribution(attribution: HTMLElement): void {
clearTimeout(this._attributionTimeout);
this._attributionObserver?.disconnect();
let byUser = false;
attribution
.querySelector(".maplibregl-ctrl-attrib-button")
?.addEventListener("click", () => {
// MapLibre's click handler already ran; the observer sees this flag
byUser = true;
queueMicrotask(() => {
byUser = false;
});
});
let shown = false;
const sync = () => {
const nowShown = attribution.classList.contains(ATTRIBUTION_SHOWN_CLASS);
if (nowShown === shown) {
return;
}
shown = nowShown;
clearTimeout(this._attributionTimeout);
if (shown && !byUser) {
this._attributionTimeout = window.setTimeout(() => {
attribution.classList.remove(ATTRIBUTION_SHOWN_CLASS);
}, ATTRIBUTION_SHOW_DURATION);
}
};
this._attributionObserver = new MutationObserver(sync);
this._attributionObserver.observe(attribution, {
attributes: true,
attributeFilter: ["class"],
});
sync();
}
private _scheduleFatal(): void {
clearTimeout(this._fallbackTimeout);
// Backgrounding drops the context too, and there it comes back on return
@@ -413,6 +530,10 @@ export class MapLibreMapEngine implements MapEngine {
this._unsubscribeToken?.();
clearTimeout(this._fallbackTimeout);
clearTimeout(this._clusterRebuildTimeout);
clearTimeout(this._attributionTimeout);
this._attributionObserver?.disconnect();
this._attributionObserver = undefined;
this._narrowViewport.removeEventListener("change", this._setUpAttribution);
document.removeEventListener("visibilitychange", this._handleVisibility);
this._revealEmerged(true);
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -539,6 +660,17 @@ export class MapLibreMapEngine implements MapEngine {
});
}
public getView(): MapView | undefined {
if (!this._map) {
return undefined;
}
const center = this._map.getCenter();
return {
center: [center.lat, center.lng],
zoom: this._map.getZoom() + ZOOM_OFFSET,
};
}
public setZoom(zoom: number): void {
this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET });
}
@@ -566,24 +698,75 @@ export class MapLibreMapEngine implements MapEngine {
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
const fit = this._fitFor(points, options);
if (fit) {
this._map!.fitBounds(fit.bounds, {
...fit.options,
animate: options?.animate,
});
if (!fit) {
return;
}
const map = this._map!;
if ("center" in fit) {
// cameraForBounds cannot solve a zero-area bound; center on it instead
if (this._clusterOptions) {
this._groupingZoom = fit.zoom;
try {
this._rebuildClusters(true);
} finally {
this._groupingZoom = undefined;
}
}
// Padding as an offset, not map padding, which would stick
const { top, right, bottom, left } = fit.padding;
const camera = {
center: [fit.center[1], fit.center[0]] as [number, number],
zoom: fit.zoom,
offset: [(left - right) / 2, (top - bottom) / 2] as [number, number],
animate: options?.animate,
};
// flyTo drops the offset under reduced motion (it jumps); easeTo keeps it
if (
options?.fly &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
map.flyTo(camera);
} else {
map.easeTo(camera);
}
return;
}
// Regroup now for the zoom the fit lands on
const zoom = this._clusterOptions
? map.cameraForBounds(fit.bounds, fit.options)?.zoom
: undefined;
if (zoom !== undefined) {
this._groupingZoom = zoom;
try {
this._rebuildClusters(true);
} finally {
this._groupingZoom = undefined;
}
}
map.fitBounds(fit.bounds, {
...fit.options,
animate: options?.animate,
linear: !options?.fly,
});
}
// The zoom a fit would land on, without moving the map
private _zoomAfterFit(points: MapLatLng[], options?: MapFitOptions): number {
const fit = this._fitFor(points, options);
return (
(fit && this._map!.cameraForBounds(fit.bounds, fit.options)?.zoom) ??
this._map!.getZoom()
);
}
private _fitFor(points: MapLatLng[], options?: MapFitOptions) {
private _fitFor(
points: MapLatLng[],
options?: MapFitOptions
):
| {
center: MapLatLng;
zoom: number;
padding: { top: number; right: number; bottom: number; left: number };
}
| {
bounds: [[number, number], [number, number]];
options: {
maxZoom: number | undefined;
padding: { top: number; right: number; bottom: number; left: number };
};
}
| undefined {
if (!this._map || !this._maplibre || !points.length) {
return undefined;
}
@@ -611,20 +794,19 @@ export class MapLibreMapEngine implements MapEngine {
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point, keeping the zoom unless given
return {
bounds: [
[minLng, minLat],
[minLng, minLat],
] as [[number, number], [number, number]],
options: { maxZoom: maxZoom ?? this._map.getZoom(), padding },
center: [minLat, minLng] as MapLatLng,
zoom: maxZoom ?? this._map.getZoom(),
padding,
};
}
const pad = options?.pad ?? 0.5;
const latPad = (maxLat - minLat) * pad;
const lngPad = (maxLng - minLng) * pad;
// MapLibre rejects a latitude beyond the poles
return {
bounds: [
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
[minLng - lngPad, Math.max(-90, minLat - latPad)],
[maxLng + lngPad, Math.min(90, maxLat + latPad)],
] as [[number, number], [number, number]],
options: { maxZoom, padding },
};
@@ -661,7 +843,7 @@ export class MapLibreMapEngine implements MapEngine {
): MapEditableMarkerHandle {
element.style.width = `${options.size[0]}px`;
element.style.height = `${options.size[1]}px`;
if (options.title) {
if (options.title && options.nativeTitle !== false) {
element.title = options.title;
}
const interactive = options.interactive ?? true;
@@ -671,6 +853,7 @@ export class MapLibreMapEngine implements MapEngine {
element.style.pointerEvents = "none";
}
setMarkerAccessibility(element, options.title, focusable);
element.style.zIndex = options.raised ? "1" : "";
if (draggable) {
// The engine, not the host, knows whether this element really drags
element.style.cursor = "move";
@@ -773,25 +956,31 @@ export class MapLibreMapEngine implements MapEngine {
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
): MapCircleHandle {
if (!this._map) {
return { remove: () => undefined };
return { update: () => undefined, remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`;
this._addCustomSource(id, circlePolygon(center, options.radius));
this._addCustomSource(id, coloredCircle(center, options));
this._addCustomLayer({
id: `${id}-fill`,
type: "fill",
source: id,
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.2 },
});
this._addCustomLayer({
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": options.color, "line-width": 3 },
paint: {
"line-color": ["get", "color"],
"line-width": ["case", ["get", "outline"], 3, 0],
},
});
return {
update: (newCenter, newOptions) => {
this._setCustomSourceData(id, coloredCircle(newCenter, newOptions));
},
remove: () => {
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
@@ -853,7 +1042,7 @@ export class MapLibreMapEngine implements MapEngine {
}
centerEl.style.width = `${centerSize[0]}px`;
centerEl.style.height = `${centerSize[1]}px`;
if (options.title) {
if (options.title && options.nativeTitle !== false) {
centerEl.title = options.title;
}
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
@@ -871,9 +1060,26 @@ export class MapLibreMapEngine implements MapEngine {
let resizeMarker: MapLibreMarker | undefined;
let resizeHandle: HTMLElement | undefined;
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
// Hidden while the circle is under the center element
const syncResizeHandleVisibility = () => {
if (!resizeHandle || dragging) {
return;
}
const east = pointEastOf(currentCenter, currentRadius);
const centerPoint = map.project([currentCenter[1], currentCenter[0]]);
const eastPoint = map.project([east[1], east[0]]);
const covered =
Math.hypot(eastPoint.x - centerPoint.x, eastPoint.y - centerPoint.y) <
centerSize[0] / 2;
resizeHandle.style.display =
covered && !resizeHandle.matches(":focus-visible") ? "none" : "";
};
const placeResizeHandle = () => {
const east = pointEastOf(currentCenter, currentRadius);
resizeMarker?.setLngLat([east[1], east[0]]);
syncResizeHandleVisibility();
const radiusText = String(Math.round(currentRadius));
resizeHandle?.setAttribute(
"aria-valuemax",
@@ -884,10 +1090,8 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
if (options.resizable) {
map.on("zoom", syncResizeHandleVisibility);
const east = pointEastOf(center, options.radius);
resizeHandle = createResizeHandleElement(options.resizeLabel);
resizeMarker = new maplibre.Marker({
@@ -947,7 +1151,10 @@ export class MapLibreMapEngine implements MapEngine {
};
resizeHandle.addEventListener("keyup", commitKeyboardResize);
// Focus can leave while a key is still held
resizeHandle.addEventListener("blur", commitKeyboardResize);
resizeHandle.addEventListener("blur", () => {
commitKeyboardResize();
syncResizeHandleVisibility();
});
// Like the other markers: a click on the handle is not a map click
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
}
@@ -958,6 +1165,7 @@ export class MapLibreMapEngine implements MapEngine {
});
handleMarker?.on("dragend", () => {
dragging = false;
syncResizeHandleVisibility();
});
});
@@ -1031,6 +1239,7 @@ export class MapLibreMapEngine implements MapEngine {
resetMarkerElement(centerEl);
clearMarkerAccessibility(centerEl);
this._placedElements.delete(centerEl);
map.off("zoom", syncResizeHandleVisibility);
resizeMarker?.remove();
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
@@ -1039,46 +1248,20 @@ export class MapLibreMapEngine implements MapEngine {
};
}
public addPath(path: MapPath): MapItemHandle {
public addPath(path: MapPath): MapPathHandle {
if (!this._map || !this._maplibre) {
return { remove: () => undefined };
return { update: () => undefined, remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`;
const lines: FeatureCollection = {
type: "FeatureCollection",
features: path.segments.map((segment) => ({
type: "Feature",
properties: { opacity: segment.opacity ?? 1 },
geometry: {
type: "LineString",
coordinates: segment.points.map((point) => [point[1], point[0]]),
},
})),
};
const points: FeatureCollection = {
type: "FeatureCollection",
features: path.markers.map((pathMarker) => ({
type: "Feature",
properties: {
opacity: pathMarker.opacity ?? 1,
tooltip: pathMarker.tooltipHtml,
},
geometry: {
type: "Point",
coordinates: [pathMarker.location[1], pathMarker.location[0]],
},
})),
};
this._addCustomSource(`${id}-lines`, lines);
this._addCustomSource(`${id}-points`, points);
this._addCustomSource(`${id}-lines`, pathLines(path));
this._addCustomSource(`${id}-points`, pathPoints(path));
this._addCustomLayer({
id: `${id}-lines`,
type: "line",
source: `${id}-lines`,
paint: {
"line-color": path.color,
"line-color": ["get", "color"],
"line-width": 3,
"line-opacity": ["get", "opacity"],
},
@@ -1089,10 +1272,10 @@ export class MapLibreMapEngine implements MapEngine {
source: `${id}-points`,
paint: {
"circle-radius": isTouch ? 8 : 3,
"circle-color": path.color,
"circle-color": ["get", "color"],
"circle-opacity": ["get", "opacity"],
"circle-stroke-width": 2,
"circle-stroke-color": path.color,
"circle-stroke-color": ["get", "color"],
"circle-stroke-opacity": ["get", "opacity"],
},
});
@@ -1135,6 +1318,10 @@ export class MapLibreMapEngine implements MapEngine {
this._pathPointLayers.add(layerId);
return {
update: (next) => {
this._setCustomSourceData(`${id}-lines`, pathLines(next));
this._setCustomSourceData(`${id}-points`, pathPoints(next));
},
remove: () => {
map.off("mouseenter", layerId, onEnter);
map.off("mouseleave", layerId, onLeave);
@@ -1455,21 +1642,14 @@ export class MapLibreMapEngine implements MapEngine {
}
const { icon } = group;
this._placeIcon(group, icon);
// Zooms in on the members, regrouped up front as they will sit once the
// zoom lands: leavers part now and the bubble opens for the rest
const zoomToMembers = () => {
const { members } = group;
const locations = members.map((managed) => managed.location);
const fit = { pad: 0.3, maxZoom: this._getMaxZoom() };
this._openAfterRegroup = members;
this._groupingZoom = this._zoomAfterFit(locations, fit);
try {
this._rebuildClusters(true, group);
} finally {
this._groupingZoom = undefined;
}
this.fitBounds(
members.map((managed) => managed.location),
{ pad: 0.3, maxZoom: this._getMaxZoom() }
);
this._hideEmerging(members);
this.fitBounds(locations, fit);
};
setMarkerAccessibility(icon.element, this._groupTitle(group), true);
icon.element.addEventListener("click", (ev) => {
+33 -33
View File
@@ -4,39 +4,42 @@ 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.
* Map colors for entities, from their place in the registry, so an entity has
* the same color on every map. Zones have their own sequence. Persons and
* trackers share another, persons first, so trackers discovered over time
* shift neither. 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"];
const byCreation = (a: EntityRegistryEntry, b: EntityRegistryEntry) =>
a.created_at - b.created_at || a.id.localeCompare(b.id);
// 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;
}
);
const domainEntries = (entries: EntityRegistryEntry[], domain: string) =>
entries
.filter((entry) => computeDomain(entry.entity_id) === domain)
.sort(byCreation);
// For entities without a registry entry
const paletteIndex = memoizeOne((entries: EntityRegistryEntry[]) => {
// The home zone has a fixed color and does not take a palette slot
const zones = domainEntries(entries, "zone").filter(
(entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID
);
const personsAndTrackers = [
...domainEntries(entries, "person"),
...domainEntries(entries, "device_tracker"),
];
const byEntityId: Record<string, number> = {};
[zones, personsAndTrackers].forEach((sequence) =>
sequence.forEach((entry, i) => {
byEntityId[entry.entity_id] = i;
})
);
return { byEntityId, zoneCount: zones.length };
});
// For entities outside both sequences
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
@@ -52,11 +55,11 @@ export const entityMapColor = (
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
paletteIndex(entries).byEntityId[entityId] ?? hashIndex(entityId),
computedStyles
);
/** The color a new zone will take once created, from the next creation-order slot */
/** The color a new zone will take once created, from the slot after the last zone */
export const nextZoneColor = (
passive: boolean,
entries: EntityRegistryEntry[],
@@ -65,10 +68,7 @@ export const nextZoneColor = (
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return getColorByIndex(
Object.keys(creationIndex(entries)).length,
computedStyles
);
return getColorByIndex(paletteIndex(entries).zoneCount, computedStyles);
};
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
+38 -38
View File
@@ -8,46 +8,46 @@
/** Drawn on the light palette */
export const HA_MAP_COLORS = {
background: "#f4efe6",
land: "#f4efe6",
water: "#bcd9e8",
labelWater: "#5b86a3",
natureWood: "#cfe1bf",
natureGrass: "#e2ecda",
naturePark: "#cfe1bf",
natureLeisure: "#e2ecda",
natureAgriculture: "#e8eee2",
natureWetland: "#e5ebe0",
natureSand: "#f1eadf",
natureRock: "#f1eadf",
glacier: "#f9f6f1",
areaResidential: "#efe8dc",
areaCommercial: "#f4e1db",
areaIndustrial: "#ede5ce",
areaWaste: "#ece3d5",
areaBurial: "#efe8dc",
siteParking: "#ede6d8",
siteSports: "#cfe1bf26",
building: "#e7ddca",
buildingBg: "#dcccb2",
background: "#f5efe5",
land: "#f5efe5",
water: "#b8daec",
labelWater: "#5487aa",
natureWood: "#cfe4bc",
natureGrass: "#e2eed8",
naturePark: "#cfe4bc",
natureLeisure: "#e2eed8",
natureAgriculture: "#e8efe1",
natureWetland: "#e5ecdf",
natureSand: "#f3eadd",
natureRock: "#f3eadd",
glacier: "#faf6f0",
areaResidential: "#f1e9da",
areaCommercial: "#f7e0d8",
areaIndustrial: "#f0e7cb",
areaWaste: "#eee3d3",
areaBurial: "#f1e9da",
siteParking: "#efe7d6",
siteSports: "#cfe4bc26",
building: "#eadec7",
buildingBg: "#e0cdae",
roadStreet: "#fcfaf8",
roadStreetBg: "#e6dac7",
roadTrunk: "#fbe6c2",
roadTrunkBg: "#e5cea5",
roadMotorway: "#ebd4ab",
roadMotorwayBg: "#deba78",
transitRail: "#e9dfce",
transitSubway: "#eae1d1",
transitCycle: "#f2ede3",
transitFoot: "#f2ede3",
boundary: "#d6c3a4",
boundaryDisputed: "#e2d6c0",
label: "#4a463d",
roadStreetBg: "#e9dbc4",
roadTrunk: "#ffe7be",
roadTrunkBg: "#ebd09f",
roadMotorway: "#f1d6a5",
roadMotorwayBg: "#e8bd6e",
transitRail: "#ece0cb",
transitSubway: "#ece2cf",
transitCycle: "#f3eee2",
transitFoot: "#f3eee2",
boundary: "#dbc49f",
boundaryDisputed: "#e5d7bd",
label: "#4b473c",
labelHalo: "#faf8f5cc",
labelSymbol: "#8d8676",
labelPoi: "#8d867666",
labelShield: "#faf8f4",
labelHousenumber: "#8d86764d",
labelSymbol: "#8f8774",
labelPoi: "#8f877466",
labelShield: "#fbf8f3",
labelHousenumber: "#8f87744d",
};
/** Drawn on the dark palette, in full: a light value left standing here would
+57 -2
View File
@@ -11,6 +11,11 @@ import type { ResolvedMapStyle } from "./map-styles";
export type MapLatLng = [latitude: number, longitude: number];
export interface MapView {
center: MapLatLng;
zoom: number;
}
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
@@ -23,6 +28,11 @@ export interface MapEngineEvents {
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
/**
* The map has something to show: MapLibre's first complete view, or Leaflet's
* layer in place. The host keeps the container hidden until then.
*/
drawn(): void;
}
export interface MapEngineOptions {
@@ -45,6 +55,8 @@ export interface MapFitOptions {
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Fly in an arc instead of easing straight there; defaults to false */
fly?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
@@ -61,12 +73,16 @@ export interface MapMarkerOptions {
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];
/** Drawn above the other markers */
raised?: boolean;
/** 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;
/** Also show the title as the browser's tooltip; defaults to true */
nativeTitle?: boolean;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
@@ -80,6 +96,8 @@ export interface MapCircleOptions {
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Stroke the circle; defaults to true */
outline?: boolean;
}
export interface MapPathSegment {
@@ -110,6 +128,16 @@ export interface MapMarkerHandle extends MapItemHandle {
readonly clusterData?: unknown;
}
export interface MapCircleHandle extends MapItemHandle {
/** Move, resize or recolor without removing it first */
update(center: MapLatLng, options: MapCircleOptions): void;
}
export interface MapPathHandle extends MapItemHandle {
/** Replace the drawn trail without removing it first */
update(path: MapPath): void;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
@@ -123,6 +151,7 @@ export interface MapEditableCircleOptions {
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
nativeTitle?: boolean;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
@@ -215,6 +244,8 @@ export interface MapEngine {
setView(center: MapLatLng, zoom?: number): void;
getView(): MapView | undefined;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
@@ -234,13 +265,13 @@ export interface MapEngine {
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
addCircle(center: MapLatLng, options: MapCircleOptions): MapCircleHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
addPath(path: MapPath): MapPathHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
@@ -320,3 +351,27 @@ export const circleBoundsPoints = (
[latMax, center[1] + dLng],
];
};
const MERCATOR_MAX_LAT = 85.051129;
const projectMercator = ([lat, lng]: MapLatLng, scale: number) => {
const sinLat = Math.sin(
toRadians(Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat)))
);
return {
x: (lng / 360 + 0.5) * scale,
y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale,
};
};
export const pixelDistance = (
a: MapLatLng,
b: MapLatLng,
zoom: number
): number => {
const scale = 256 * 2 ** zoom;
const pa = projectMercator(a, scale);
const pb = projectMercator(b, scale);
const dx = Math.abs(pa.x - pb.x);
return Math.hypot(Math.min(dx, scale - dx), pa.y - pb.y);
};
+13 -2
View File
@@ -59,7 +59,7 @@ const NAME_EN: ExpressionSpecification = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN: ExpressionSpecification = ["<", NAME, "ɐ"];
const IS_LATIN: ExpressionSpecification = ["<", ["coalesce", NAME, ""], "ɐ"];
const ENGLISH_SCALE = 0.8;
@@ -98,8 +98,19 @@ const addLatinLabels = (style: StyleSpecification): StyleSpecification => ({
}),
});
// The atmosphere MapLibre scatters around the globe, which VersaTiles leaves
// off for a crisp edge. Against the space ha-map draws behind it, that edge is
// what wants softening.
const ATMOSPHERE_BLEND = 0.5;
const addAtmosphere = (style: StyleSpecification): StyleSpecification => ({
...style,
sky: { ...style.sky, "atmosphere-blend": ATMOSPHERE_BLEND },
});
/** Everything a freshly built style needs before it is handed to MapLibre */
export const finalizeMapStyle = (
name: string,
style: StyleSpecification
): StyleSpecification => addLatinLabels(useTileJson(name, style));
): StyleSpecification =>
addAtmosphere(addLatinLabels(useTileJson(name, style)));
+9 -3
View File
@@ -1,13 +1,19 @@
import { getContrastedColorHex } from "../color/rgb";
import { wcagContrast } from "culori";
import { theme2hex } from "../color/convert-color";
/** 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
// WCAG minimum contrast for icons and other graphics
const MIN_CONTENT_CONTRAST = 3;
// White content while it contrasts enough with the fill, black otherwise;
// not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
const whiteContrast = wcagContrast(theme2hex(color.trim()), "#ffffff");
return whiteContrast >= MIN_CONTENT_CONTRAST ? "#ffffff" : "#000000";
} catch {
return "#ffffff";
}
@@ -436,7 +436,7 @@ export function generateStateHistoryChartLineData(
const stateObj = hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([nowMs, currentValue]);
data[0].data!.push([Math.max(nowMs, endTimeMs), currentValue]);
trackY(currentValue);
}
}
@@ -106,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
private _hiddenStats = new Set<string>();
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
@state() private _yWidth = 0;
@state() private _visualMap?: VisualMapComponentOption[];
@@ -213,7 +213,7 @@ export class StateHistoryChartTimeline extends LitElement {
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
: this.paddingYAxis;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
+26 -2
View File
@@ -273,7 +273,12 @@ export class StateHistoryCharts extends LitElement {
)
) {
// Don't recompute times when we just want to update layout
const now = new Date();
// A browser clock behind the server's would put the newest states past
// the end time, and the charts drop those. Rounded up because Date
// truncates their sub-millisecond timestamps.
const now = new Date(
Math.max(Date.now(), Math.ceil(this._newestStateTime()))
);
this._computedEndTime =
this.upToNow || !this.endTime || this.endTime > now
@@ -284,7 +289,7 @@ export class StateHistoryCharts extends LitElement {
this._computedStartTime = this.startTime;
} else if (this.hoursToShow) {
this._computedStartTime = new Date(
new Date().getTime() - 60 * 60 * this.hoursToShow * 1000
now.getTime() - 60 * 60 * this.hoursToShow * 1000
);
} else {
let minTimeAll = (this.historyData?.timeline ?? []).reduce(
@@ -383,6 +388,25 @@ export class StateHistoryCharts extends LitElement {
});
}
private _newestStateTime(): number {
let newest = 0;
for (const entity of this.historyData?.timeline ?? []) {
newest = Math.max(
newest,
entity.data[entity.data.length - 1]?.last_changed ?? 0
);
}
for (const unit of this.historyData?.line ?? []) {
for (const entity of unit.data) {
newest = Math.max(
newest,
entity.states[entity.states.length - 1]?.last_changed ?? 0
);
}
}
return newest;
}
private _isHistoryEmpty(): boolean {
const historyDataEmpty =
!this.historyData ||
+1
View File
@@ -332,6 +332,7 @@ export class HaChipBase extends HaButton {
display: inline-flex;
max-width: 100%;
vertical-align: middle;
border-radius: var(--ha-button-border-radius);
--ha-button-height: 32px;
--ha-button-border-radius: var(--ha-border-radius-pill);
--wa-font-weight-action: var(--ha-font-weight-normal);
@@ -66,15 +66,15 @@ export class DialogDataTableSettings extends LitElement {
}
private _preserveLastFixed(columnOrder) {
let strippedColumnOrder;
const lastFixedCount = this._lastFixedCount();
if (lastFixedCount && columnOrder) {
strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
if (!lastFixedCount || !columnOrder) {
return columnOrder;
}
const strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
return strippedColumnOrder;
}
+8
View File
@@ -82,6 +82,14 @@ export const greetingTranslationLanguage = (
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
return undefined;
}
// A bare pipeline language ("zh") covers the interface's variant of it
// ("zh-Hans"), which a lookup of the bare language may not pick
if (
!pipelineLanguage.includes("-") &&
interfaceLanguage?.startsWith(`${pipelineLanguage}-`)
) {
return undefined;
}
const language = findAvailableLanguage(pipelineLanguage);
return language && language !== interfaceLanguage ? language : undefined;
};
+3 -35
View File
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { isSwipeLocked } from "../common/dom/is-swipe-locked";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { configContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
@@ -14,19 +15,6 @@ import { isIosApp } from "../util/is_ios";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
const SWIPE_LOCKED_COMPONENTS = new Set([
"ha-control-slider",
"ha-slider",
"ha-control-switch",
"ha-control-circular-slider",
"ha-hs-color-picker",
"ha-map",
"ha-more-info-control-select-container",
"ha-filter-chip",
]);
const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
/**
* Home Assistant bottom sheet component.
*
@@ -255,28 +243,8 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
return;
}
const path = ev.composedPath();
for (const target of path) {
if (target === this._drawer) {
break;
}
if (!(target instanceof HTMLElement)) {
continue;
}
if (
// Check if any element inside drawer in the composed path has scrollTop > 0 (list)
target.scrollTop > 0 ||
// Check if the element is a swipe locked component or has a swipe locked class
SWIPE_LOCKED_COMPONENTS.has(target.localName) ||
Array.from(target.classList).some((cls) =>
SWIPE_LOCKED_CLASSES.has(cls)
)
) {
return;
}
if (isSwipeLocked(ev.composedPath(), this._drawer)) {
return;
}
// Stop propagation so parent bottom sheets don't also start tracking
+17
View File
@@ -32,6 +32,7 @@ import { customElement } from "lit/decorators";
* @cssprop --ha-checkbox-checked-icon-scale - The size of the checked and indeterminate icons relative to the checkbox. Defaults to `0.9`.
* @cssprop --ha-checkbox-border-radius - The border radius of the checkbox control. Defaults to `--ha-border-radius-sm`.
* @cssprop --ha-checkbox-border-width - The border width of the checkbox control. Defaults to `--ha-border-width-md`.
* @cssprop --ha-checkbox-min-touch-size - Minimum touch target size around the checkbox. Defaults to `40px`.
* @cssprop --ha-checkbox-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
* @cssprop --ha-checkbox-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
@@ -114,6 +115,22 @@ export class HaCheckbox extends WaCheckbox {
margin-inline-end: 0;
}
[part~="control"]::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
min-width: var(--ha-checkbox-min-touch-size, 40px);
min-height: var(--ha-checkbox-min-touch-size, 40px);
}
label:has(input:disabled) [part~="control"]::before {
pointer-events: none;
}
[part~="label"] {
line-height: 1;
}
+16
View File
@@ -334,6 +334,22 @@ export class HaControlSelect extends LitElement {
width: 100%;
hyphens: auto;
}
:host(:not([vertical])) .option {
min-width: 0;
}
:host(:not([vertical])) .option .content {
padding-inline: var(--ha-space-1);
}
:host(:not([vertical])) .option .content span {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
hyphens: manual;
line-height: var(--ha-line-height-condensed);
}
:host([vertical]) {
width: var(--control-select-thickness);
height: auto;
+9 -4
View File
@@ -108,6 +108,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@property({ attribute: "header-title" })
public headerTitle?: string;
/** Accessible name for dialogs that put their title in a slot. */
@property({ attribute: false })
public accessibleLabel?: string;
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@@ -157,15 +161,16 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
protected render() {
// The title lives in a shadow root that an aria-labelledby IDREF on
// wa-dialog's inner <dialog> can't reach. Name the dialog with the title
// text instead, which wa-dialog uses as aria-label without its own header.
return html`
<wa-dialog
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-dialog-title" : undefined)
)}
.label=${this.accessibleLabel ?? this.headerTitle ?? ""}
aria-labelledby=${ifDefined(this.ariaLabelledBy)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@keydown=${this._handleKeyDown}
@wa-hide=${this._handleHide}
+7
View File
@@ -59,6 +59,13 @@ export class HaDropdownItem extends DropdownItem {
:host([selected]:hover) {
background-color: var(--ha-color-fill-primary-quiet-hover);
}
/* Long submenus, like the label list on a phone, scroll instead of
running off the screen. */
#submenu[popover] {
max-height: calc(100dvh - var(--ha-space-4));
overflow-y: auto;
}
`,
];
}
+16
View File
@@ -110,6 +110,22 @@ export class HaDropdown extends Dropdown {
wa-popup::part(popup) {
z-index: 200;
}
/* The auto size ignores safe areas, so a long menu could end up
behind the Android navigation bar or the notch. */
wa-popup[data-current-placement^="bottom"] #menu {
max-height: calc(
var(--auto-size-available-height) - var(
--safe-area-inset-bottom,
0px
)
) !important;
}
wa-popup[data-current-placement^="top"] #menu {
max-height: calc(
var(--auto-size-available-height) - var(--safe-area-inset-top, 0px)
) !important;
}
`,
];
}
+3 -4
View File
@@ -335,11 +335,10 @@ class HaHLSPlayer extends LitElement {
lowLatencyMode: this._isLLHLSSupported(),
});
this._hlsPolyfillInstance = hls;
// Load the source once, before attaching. MEDIA_ATTACHED fires again on
// every recoverMediaError(), and loading there would restart the stream.
hls.loadSource(url);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
this._resetError();
hls.loadSource(url);
});
hls.on(Hls.Events.FRAG_LOADED, (_event, _data: any) => {
this._resetError();
});
+5 -3
View File
@@ -12,7 +12,9 @@ export class HaIconButton extends LitElement {
// SVG icon path (if you need a non SVG icon instead, use the provided slot to pass an <ha-icon> in)
@property({ type: String }) path?: string;
// Label that is used for ARIA support and as tooltip
// Label that is used for ARIA support and as tooltip. Passed to ha-button as
// properties, so only its inner button gets them: as attributes on the
// ha-button host, screen readers also announce the host as a named group.
@property({ type: String }) label?: string;
// These should always be set as properties, not attributes,
@@ -42,8 +44,8 @@ export class HaIconButton extends LitElement {
<ha-button
appearance="plain"
variant="neutral"
aria-label=${ifDefined(this.label)}
title=${ifDefined(this.hideTitle ? undefined : this.label)}
.ariaLabel=${this.label ?? null}
.title=${this.hideTitle ? "" : (this.label ?? "")}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled}
.iconTag=${this.path ? "ha-svg-icon" : "span"}
+8 -1
View File
@@ -79,6 +79,10 @@ const isFullWidthSelector = (selector?: Selector): boolean =>
FULL_WIDTH_SELECTOR_TYPES.has(selectorType)
);
// A switch fits next to its label, even when the editor is narrow.
const isBooleanSelector = (selector?: Selector): boolean =>
!!selector && "boolean" in selector;
interface Field extends Omit<HassService["fields"][string], "selector"> {
key: string;
selector?: Selector;
@@ -693,7 +697,10 @@ export class HaServiceControl extends LitElement {
return dataField.selector
? html`<ha-settings-row
.narrow=${this.narrow || isFullWidthSelector(selector)}
.narrow=${
(this.narrow && !isBooleanSelector(selector)) ||
isFullWidthSelector(selector)
}
>
${
!showOptional
+571
View File
@@ -0,0 +1,571 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { isSwipeLocked } from "../common/dom/is-swipe-locked";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
export type BottomSheetPosition = "collapsed" | "half" | "full";
const HALF_VIEWPORT_SHARE = 0.5;
// How far a touch moves before its direction decides what it drags
const TOUCH_SLOP_PX = 4;
interface Stop {
position: BottomSheetPosition;
height: number;
}
/**
* A bottom sheet that slides up from the bottom of the screen without blocking
* the page behind it. It sits in the top layer, above the toolbar, like other
* sheets; a backdrop fades in as it rises from half to full height, and a tap
* on it brings the sheet back to half.
*
* It rests collapsed, at half or at full height and snaps to the nearest of
* them when a drag ends, or to the next one in the direction of a quick swipe.
* The handle drags with a mouse or a finger. On touch the whole sheet drags
* too, until it is at full height: then its content scrolls, and pulling down
* from the top brings the sheet down again. Dragging down never closes it; it
* collapses to `minHeight` so a host keeps a strip of content reachable, and
* only `closeSheet` closes it.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels
* whenever it settles at a new height, or when `halfHeight` changes
*
* @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-snap-bottom-sheet")
export class HaSnapBottomSheet extends LitElement {
@query(".sheet") private _sheet!: HTMLElement;
@query(".backdrop") private _backdrop!: HTMLElement;
@query(".handle") private _handle!: HTMLElement;
/**
* The height in pixels of the strip the collapsed sheet keeps visible, e.g.
* what a host measures; the sheet adds the bottom safe area below it.
* Without it the sheet does not collapse.
*/
@property({ type: Number, attribute: "min-height" })
public minHeight?: number;
/** Names the sheet for assistive technologies */
@property() public label?: string;
@state() private _shown = false;
@state() private _position: BottomSheetPosition = "half";
@state() private _dragHeight?: number;
private _gesture = new SwipeGestureRecognizer();
private _dragging = false;
private _dragStartHeight = 0;
// A touch on the content that has not moved yet, so it is unknown whether
// it drags the sheet, scrolls the content or is a tap
private _pendingTouch?: { x: number; y: number };
private _reported?: { height: number; halfHeight: number };
private _closing = false;
public get position(): BottomSheetPosition {
return this._position;
}
public get halfHeight(): number {
return window.innerHeight * HALF_VIEWPORT_SHARE;
}
public snapTo(position: BottomSheetPosition) {
this._position = position;
}
public async closeSheet() {
if (this._closing) {
return;
}
this._closing = true;
this._shown = false;
await this._transitionsDone();
if (popoverSupported) {
this._sheet.hidePopover();
this._backdrop.hidePopover();
}
fireEvent(this, "bottom-sheet-closed");
}
render() {
const full = this._position === "full";
const dragging = this._dragHeight !== undefined;
const popover = ifDefined(popoverSupported ? "manual" : undefined);
return html`<div
class="backdrop ${classMap({ active: this._shown && full, dragging })}"
popover=${popover}
style=${styleMap({ opacity: String(this._backdropOpacity()) })}
@click=${this._handleBackdropClick}
></div>
<div
class="sheet ${classMap({ show: this._shown, full, dragging })}"
popover=${popover}
role="region"
aria-label=${ifDefined(this.label)}
style=${styleMap({
height: this._height(),
"min-height": this._collapsedHeight(),
})}
@touchstart=${this._handleTouchStart}
>
<div class="handle-wrapper">
<div @mousedown=${this._handleMouseDown} class="handle"></div>
</div>
<slot></slot>
</div>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._showPopovers();
requestAnimationFrame(() => {
if (!this._closing) {
this._shown = true;
}
});
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
changedProperties.has("_position") ||
changedProperties.has("minHeight") ||
(changedProperties.has("_dragHeight") && this._dragHeight === undefined)
) {
this._reportWhenSettled();
}
}
connectedCallback() {
super.connectedCallback();
// Leaving the page, e.g. with a cached dashboard view, took the sheet out
// of the top layer, and the host may have dropped its height meanwhile
if (this.hasUpdated && this._shown) {
this._showPopovers();
this._reported = undefined;
this._reportWhenSettled();
}
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
window.addEventListener("resize", this._handleViewportResize);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._dragging) {
document.body.style.removeProperty("cursor");
}
this._dragging = false;
this._pendingTouch = undefined;
this._dragHeight = undefined;
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
window.removeEventListener("resize", this._handleViewportResize);
}
private _collapsedHeight(): string | undefined {
return this.minHeight === undefined
? undefined
: `calc(${this.minHeight}px + var(--safe-area-inset-bottom, 0px))`;
}
private _height(): string | undefined {
if (this._dragHeight !== undefined) {
return `${this._dragHeight}px`;
}
return this._position === "collapsed" ? this._collapsedHeight() : undefined;
}
private _showPopovers() {
if (!popoverSupported) {
return;
}
// Shown first so the sheet stacks above it
for (const element of [this._backdrop, this._sheet]) {
if (!element.matches(":popover-open")) {
element.showPopover();
}
}
}
private _backdropOpacity(): number {
if (!this._shown) {
return 0;
}
if (this._dragHeight === undefined) {
return this._position === "full" ? 1 : 0;
}
const half = this.halfHeight;
const progress = (this._dragHeight - half) / (this._fullHeight() - half);
return Math.min(1, Math.max(0, progress));
}
private _handleBackdropClick = () => {
this._position = "half";
};
// The half and full heights follow the viewport, for example on rotation
private _handleViewportResize = () => {
this._reportWhenSettled();
};
private _handleMouseDown = (ev: MouseEvent) => {
document.body.style.setProperty("cursor", "grabbing");
this._startDrag(ev.clientY);
};
private _handleMouseMove = (ev: MouseEvent) => {
if (this._dragging) {
this._moveDrag(ev.clientY);
}
};
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchStart = (ev: TouchEvent) => {
if (ev.touches.length !== 1) {
return;
}
const touch = ev.touches[0];
const path = ev.composedPath();
if (path.includes(this._handle)) {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(touch.clientY);
return;
}
const content = path.slice(0, path.indexOf(this._sheet));
// Dialogs and menus opened inside the content handle their own swipes
if (
content.some(
(target) =>
target instanceof HTMLDialogElement ||
(popoverSupported &&
target instanceof HTMLElement &&
target.matches(":popover-open"))
) ||
isSwipeLocked(content, this._sheet)
) {
return;
}
this._pendingTouch = { x: touch.clientX, y: touch.clientY };
};
private _handleTouchMove = (ev: TouchEvent) => {
const touch = ev.touches[0];
if (this._pendingTouch) {
const start = this._pendingTouch;
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (ev.cancelable && Math.hypot(deltaX, deltaY) < TOUCH_SLOP_PX) {
return;
}
this._pendingTouch = undefined;
// Sideways moves and scrolls the browser already started stay with the
// content, and so does scrolling up once the sheet is at full height
if (
!ev.cancelable ||
Math.abs(deltaX) > Math.abs(deltaY) ||
(deltaY < 0 && this._position === "full")
) {
return;
}
this._startDrag(start.y);
}
if (!this._dragging) {
return;
}
ev.preventDefault();
this._moveDrag(touch.clientY);
};
private _handleTouchEnd = () => {
this._pendingTouch = undefined;
this._endDrag();
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartHeight = this._sheet.offsetHeight;
this._gesture.start(clientY);
}
private _moveDrag(clientY: number) {
const delta = this._gesture.move(clientY);
this._dragHeight = Math.max(
this._stops()[0].height,
this._dragStartHeight + delta
);
}
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
const { isSwipe, isDownwardSwipe } = this._gesture.end();
this._position = this._stopAfterDrag(
this._sheet.offsetHeight,
isSwipe ? (isDownwardSwipe ? "down" : "up") : undefined
);
this._dragHeight = undefined;
}
// A swipe goes on to the next stop in its direction, a slow drag settles on
// the nearest one
private _stopAfterDrag(
height: number,
direction?: "up" | "down"
): BottomSheetPosition {
const stops = this._stops();
if (direction === "up") {
return (
stops.find((stop) => stop.height > height) ?? stops[stops.length - 1]
).position;
}
if (direction === "down") {
return (
[...stops].reverse().find((stop) => stop.height < height) ?? stops[0]
).position;
}
return stops.reduce((nearest, stop) =>
Math.abs(stop.height - height) < Math.abs(nearest.height - height)
? stop
: nearest
).position;
}
private _stops(): Stop[] {
const stops: Stop[] = [];
if (this.minHeight !== undefined) {
stops.push({
position: "collapsed",
height: parseFloat(getComputedStyle(this._sheet).minHeight),
});
}
stops.push(
{ position: "half", height: this.halfHeight },
{ position: "full", height: this._fullHeight() }
);
return stops;
}
private _fullHeight(): number {
return parseFloat(getComputedStyle(this._sheet).maxHeight);
}
private async _transitionsDone(): Promise<boolean> {
await this.updateComplete;
try {
await Promise.all(
this._sheet.getAnimations().map((animation) => animation.finished)
);
return true;
} catch {
// A newer change interrupted the transition and settles on its own
return false;
}
}
// Hosts lay out around the sheet once it settles, not on every frame
private async _reportWhenSettled() {
if (!(await this._transitionsDone()) || this._dragging) {
return;
}
const height = this._sheet.offsetHeight;
const halfHeight = this.halfHeight;
if (
height === this._reported?.height &&
halfHeight === this._reported.halfHeight
) {
return;
}
this._reported = { height, halfHeight };
fireEvent(this, "bottom-sheet-resized", { height });
}
static styles = css`
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
/* Extends the grab area over the content below the handle */
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
}
.handle-wrapper .handle::after {
content: "";
border-radius: var(--ha-border-radius-md);
height: 4px;
background: var(--ha-bottom-sheet-handle-color, var(--divider-color));
width: 40px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
.sheet {
height: 50vh;
height: 50dvh;
max-height: calc(100vh - max(var(--safe-area-inset-top, 0px), 48px));
max-height: calc(100dvh - max(var(--safe-area-inset-top, 0px), 48px));
background-color: var(
--ha-bottom-sheet-surface-background,
var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
)
);
backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
);
display: flex;
flex-direction: column;
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)
);
width: calc(100% - var(--sheet-inset-left) - var(--sheet-inset-right));
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
transform: translateY(100%);
transition:
transform var(--ha-animation-duration-normal) ease,
height var(--ha-animation-duration-normal) ease;
margin-left: var(--sheet-inset-left);
margin-right: var(--sheet-inset-right);
outline: none;
overflow: visible;
color: inherit;
}
.backdrop {
position: fixed;
inset: 0;
width: auto;
height: auto;
margin: 0;
padding: 0;
border: none;
-webkit-backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
)
);
backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
)
);
background-color: var(
--ha-bottom-sheet-scrim-color,
var(--mdc-dialog-scrim-color, transparent)
);
pointer-events: none;
transition: opacity var(--ha-animation-duration-normal) ease;
}
.backdrop.active {
pointer-events: auto;
}
.backdrop.dragging {
transition: none;
}
.sheet.full {
height: calc(100vh - max(var(--safe-area-inset-top, 0px), 48px));
height: calc(100dvh - max(var(--safe-area-inset-top, 0px), 48px));
}
.sheet.dragging {
transition: transform var(--ha-animation-duration-normal) ease;
}
.sheet.show {
transform: translateY(0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-snap-bottom-sheet": HaSnapBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
"bottom-sheet-resized": { height: number };
}
}
+23 -2
View File
@@ -176,8 +176,12 @@ export class HaInput extends WaInputMixin(LitElement) {
return false;
}
// Compare against wa-input, not our own value: a value set right before
// (like clearing the field and focusing it again) only reaches the native
// input on the next render, and must not be overwritten by the old text.
const nativeValue = native.value;
if ((this.value ?? "") === nativeValue) {
const currentValue = this._input ? this._input.value : this.value;
if ((currentValue ?? "") === nativeValue) {
return false;
}
@@ -212,6 +216,22 @@ export class HaInput extends WaInputMixin(LitElement) {
}
}
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
this._syncAriaInvalid();
}
// wa-input never sets aria-invalid on its native input. Without it, WebKit
// falls back to the native validity, so screen readers announce an empty
// required field as invalid before anything was typed.
private async _syncAriaInvalid(): Promise<void> {
await this._input?.updateComplete;
this._getNativeInput()?.setAttribute(
"aria-invalid",
this.invalid || this._invalid ? "true" : "false"
);
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("focusin", this._syncFromNativeInput);
@@ -423,7 +443,8 @@ export class HaInput extends WaInputMixin(LitElement) {
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
:host([type="date"]) wa-input::part(label),
:host([type="color"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
+86 -6
View File
@@ -1,11 +1,33 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, css } from "lit";
import { LitElement, html, css, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-state-icon";
export const SELECTED_MARKER_SCALE = 1.25;
// Floating: the frame hangs above a dot, a rotated-square tail between
const FLOATING_TAIL_SIZE = 12;
const FLOATING_TAIL_REACH = Math.round((FLOATING_TAIL_SIZE * Math.SQRT2) / 2);
const FLOATING_GAP = 4;
const FLOATING_DOT_SIZE = 10;
export const FLOATING_LIFT = FLOATING_GAP + FLOATING_DOT_SIZE / 2;
export const floatingMarkerFootprint = (
markerSize: number,
selected: boolean
): { size: [number, number]; anchor: [number, number] } => {
const frame = selected
? Math.round(markerSize * SELECTED_MARKER_SCALE)
: markerSize;
const height = frame + FLOATING_TAIL_REACH + FLOATING_GAP + FLOATING_DOT_SIZE;
return {
size: [frame, height],
anchor: [frame / 2, height - FLOATING_DOT_SIZE / 2],
};
};
/**
* @csspart marker - The framed avatar.
* @csspart picture - The entity picture inside the frame.
@@ -30,13 +52,14 @@ class HaEntityMarker extends LitElement {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public floating = false;
protected render() {
return html`
<div
part="marker"
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
@click=${this._badgeTap}
>
${
this.entityPicture
@@ -63,16 +86,24 @@ class HaEntityMarker extends LitElement {
`
}
</div>
${
this.floating
? html`<div class="tail"></div>
<div class="dot"></div>`
: nothing
}
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("click", this._badgeTap);
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("click", this._badgeTap);
this.removeEventListener("keydown", this._handleKeydown);
}
@@ -84,14 +115,35 @@ class HaEntityMarker extends LitElement {
}
};
private _badgeTap(ev: Event) {
private _badgeTap = (ev: Event) => {
ev.stopPropagation();
if (this.entityId) {
fireEvent(this, "hass-more-info", { entityId: this.entityId });
}
}
};
static styles = css`
:host([floating]) {
display: flex;
flex-direction: column;
align-items: center;
/* One shadow for the whole pin, so the frame casts none onto the tail */
filter: var(
--ha-cluster-shadow,
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12))
);
}
:host([floating]) {
pointer-events: none;
}
:host([floating]) .marker,
:host([floating]) .tail {
pointer-events: auto;
}
:host([floating]) .marker {
position: relative;
z-index: 1;
}
.marker {
display: flex;
justify-content: center;
@@ -104,7 +156,6 @@ class HaEntityMarker extends LitElement {
border-radius: var(--ha-marker-border-radius, 50%);
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
color: var(--primary-text-color);
background-color: var(
--ha-marker-background,
@@ -114,10 +165,39 @@ class HaEntityMarker extends LitElement {
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
width: calc(
var(--ha-marker-size, 48px) *
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
);
height: calc(
var(--ha-marker-size, 48px) *
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
);
}
/* Floating, the accuracy circle carries the color instead of a ring */
:host([selected]:not([floating])) .marker {
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
}
.tail {
width: ${FLOATING_TAIL_SIZE}px;
height: ${FLOATING_TAIL_SIZE}px;
margin-top: ${-FLOATING_TAIL_SIZE / 2}px;
margin-bottom: ${FLOATING_TAIL_REACH - FLOATING_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--ha-marker-color, var(--card-background-color, #fff));
transform: rotate(45deg);
}
.dot {
width: ${FLOATING_DOT_SIZE}px;
height: ${FLOATING_DOT_SIZE}px;
margin-top: ${FLOATING_GAP}px;
border-radius: 50%;
background: var(--ha-marker-color, var(--card-background-color, #fff));
}
:host(:not([selected])) .dot {
visibility: hidden;
}
.entity-picture {
background-size: cover;
height: 100%;
+14 -1
View File
@@ -8,7 +8,7 @@ import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import type { MapLatLng, MapView } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
@@ -47,6 +47,8 @@ export interface MarkerLocation {
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
/** Counts toward the map fit; defaults to true */
fit?: boolean;
}
const ICON_SIZE = 24;
@@ -92,6 +94,16 @@ export class HaLocationsEditor extends LitElement {
this.map.fitMap(options);
}
public getView(): MapView | undefined {
const view = this.map.getView();
return (
view && {
...view,
center: [view.center[0], this._normalizeLongitude(view.center[1])],
}
);
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
@@ -186,6 +198,7 @@ export class HaLocationsEditor extends LitElement {
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
fit: location.fit,
}));
}
);
+268 -89
View File
@@ -21,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsVectorMaps } from "../../common/map/base-layer";
import type {
MapCircleHandle,
MapClusterIcon,
MapControlPosition,
MapEngine,
@@ -29,11 +30,13 @@ import type {
MapLatLng,
MapMarkerHandle,
MapPath,
MapPathHandle,
MapPathMarker,
MapPathSegment,
MapEditableCircleHandle,
MapEditableMarkerHandle,
MapEditingSupport,
MapView,
} from "../../common/map/map-engine";
import {
circleBoundsPoints,
@@ -66,6 +69,7 @@ import {
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import "../ha-tooltip";
import type {
HomeAssistantConfig,
HomeAssistantConnection,
@@ -74,7 +78,7 @@ import type {
HomeAssistantUI,
ThemeMode,
} from "../../types";
import "./ha-entity-marker";
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
declare global {
// for fire event
@@ -85,6 +89,9 @@ declare global {
const PROGRAMMITIC_FIT_DELAY = 250;
// An engine that never reports a drawn frame must not leave an empty card
const DRAWN_FALLBACK = 3000;
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
@@ -123,6 +130,8 @@ export interface HaMapEditableLocation {
radiusEditable?: boolean;
/** Activating the marker fires editable-location-clicked; otherwise it is not a button */
activatable?: boolean;
/** Counts toward the map fit; defaults to true */
fit?: boolean;
}
// Geometry is updated in place; a change to anything else rebuilds the marker
@@ -194,6 +203,7 @@ const staticEditing = (engine: MapEngine): MapEditingSupport => ({
size: options.centerSize ?? [16, 16],
interactive: !!options.onClick,
title: options.title,
nativeTitle: options.nativeTitle,
}),
];
};
@@ -259,7 +269,8 @@ const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
// Space an opened bubble keeps from the map edges
const CLUSTER_BUBBLE_MARGIN = 12;
const CLUSTER_MAX_AVATARS = 3;
// Beyond this the last slot becomes a count
const CLUSTER_MAX_AVATARS = 4;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
@@ -270,6 +281,10 @@ const CLUSTER_RADIUS = 40;
// Same-zone markers share the zone's bubble while they span at most this many
// pixels; further apart they show their actual positions
const ZONE_GROUP_RADIUS = 160;
// Circle radius (meters) for a selected marker without a reported accuracy
const NOMINAL_ACCURACY = 2.5;
// Fixes this good (meters) draw a soft disc without an outline
const PRECISE_GPS_ACCURACY = 10;
type EntityMarkerElement = HTMLElementTagNameMap["ha-entity-marker"];
@@ -408,6 +423,8 @@ export class HaMap extends ReactiveElement {
private _entityHandles: MapMarkerHandle[] = [];
private _tooltipCount = 0;
// Marker elements survive redraws so unchanged entities keep their DOM
private _entityMarkers = new Map<string, EntityMarkerElement>();
@@ -415,7 +432,9 @@ export class HaMap extends ReactiveElement {
private _zoneHandles: MapItemHandle[] = [];
private _pathHandles: MapItemHandle[] = [];
private _zoneCircleHandles: MapCircleHandle[] = [];
private _pathHandles: MapPathHandle[] = [];
private _focusPoints: MapLatLng[] = [];
@@ -474,10 +493,12 @@ export class HaMap extends ReactiveElement {
this._startingEngine?.destroy();
this._startingEngine = undefined;
this._loading = false;
clearTimeout(this._drawnFallback);
this._entityHandles = [];
this._entityMarkers.clear();
this._clusterAvatars.clear();
this._zoneHandles = [];
this._zoneCircleHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
@@ -646,14 +667,34 @@ export class HaMap extends ReactiveElement {
// case and keeps the map on the style the build generated.
this._readThemeColors();
const map = this._mapElement!;
const map = this._mapElement;
if (!map) {
return;
}
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("drawn", this._mapDrawn);
// The sky belongs behind a drawn globe; on a blank canvas it is just a
// gradient with a glow in it
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._engine?.setMapStyle(this._resolvedMapStyle);
}
private _mapDrawn = false;
private _drawnFallback?: number;
private _markDrawn(attempt: number): void {
if (attempt !== this._setupAttempt || this._mapDrawn) {
return;
}
clearTimeout(this._drawnFallback);
this._mapDrawn = true;
this._updateMapAppearance();
}
private _loading = false;
private _forceLeaflet = false;
@@ -663,9 +704,14 @@ export class HaMap extends ReactiveElement {
private _setupAttempt = 0;
/** Whether the next engine is the vector one: only it draws a globe */
private get _vectorEngine(): boolean {
return !this._forceLeaflet && supportsVectorMaps();
}
// Each engine is its own chunk; a map only downloads the one it uses
private async _createEngine(): Promise<MapEngine> {
if (this._forceLeaflet || !supportsVectorMaps()) {
if (!this._vectorEngine) {
const leaflet =
await import("../../common/map/engines/leaflet-map-engine");
return new leaflet.LeafletMapEngine();
@@ -699,8 +745,13 @@ export class HaMap extends ReactiveElement {
this.shadowRoot!.getElementById("map")?.remove();
const map = document.createElement("div");
map.id = "map";
// Which ground shows in the gap before the first frame; the rest of the
// classes wait for the engine (_updateMapAppearance)
map.classList.toggle("dark", this._darkMode);
this.shadowRoot!.append(map);
this._loading = true;
this._mapDrawn = false;
clearTimeout(this._drawnFallback);
const attempt = ++this._setupAttempt;
let engine: MapEngine | undefined;
try {
@@ -718,6 +769,12 @@ export class HaMap extends ReactiveElement {
return;
}
this._startingEngine = engine;
// Started here so the budget covers an engine that never reports a
// frame, not the token and the chunk it waited for
this._drawnFallback = window.setTimeout(
() => this._markDrawn(attempt),
DRAWN_FALLBACK
);
await engine.init(map, {
center: [
this._config?.latitude ?? 52.3731339,
@@ -741,6 +798,7 @@ export class HaMap extends ReactiveElement {
}
},
fatal: () => this._handleEngineFatal(),
drawn: () => this._markDrawn(attempt),
},
});
// Disconnected while the style was loading, or superseded by a newer setup
@@ -785,6 +843,7 @@ export class HaMap extends ReactiveElement {
this._engine = undefined;
this._entityHandles = [];
this._zoneHandles = [];
this._zoneCircleHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
@@ -836,9 +895,12 @@ export class HaMap extends ReactiveElement {
return;
}
// Zones join the fit when asked, or when they are all there is
const zonePoints =
this.fitZones || !this._focusPoints.length ? this._focusZonePoints : [];
if (
!this._focusPoints.length &&
!this._focusZonePoints.length &&
!zonePoints.length &&
!this.editableLocations?.length
) {
this._withProgrammaticFit(() => {
@@ -851,10 +913,12 @@ export class HaMap extends ReactiveElement {
return;
}
const points = [...this._focusPoints, ...this._focusZonePoints];
const points = [...this._focusPoints, ...zonePoints];
// Editable locations contribute their bounds, radius included
this.editableLocations?.forEach((editable) => {
// Opted-out locations only count when nothing else would be fitted
const editables = this.editableLocations ?? [];
const fitted = editables.filter((editable) => editable.fit !== false);
(fitted.length ? fitted : editables).forEach((editable) => {
if (editable.radius) {
points.push(...circleBoundsPoints(editable.location, editable.radius));
} else {
@@ -912,9 +976,18 @@ export class HaMap extends ReactiveElement {
this._engine.setView(center, zoom);
}
public getView(): MapView | undefined {
return this._engine?.getView();
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
options?: {
zoom?: number;
pad?: number;
padding?: MapFitPadding;
fly?: boolean;
}
) {
// An explicit fit is user intent, even while it waits for the engine or
// a size; an auto-fit must not take its place in the meantime
@@ -933,6 +1006,7 @@ export class HaMap extends ReactiveElement {
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
padding: options?.padding,
fly: options?.fly,
});
});
this._hasFitted = true;
@@ -966,6 +1040,7 @@ export class HaMap extends ReactiveElement {
// Markers are buttons, so an unnamed location still gets a name
const title =
editable.title ?? this._i18n?.localize("ui.components.map.location");
const tooltip = !!editable.element && !!editable.title;
const existing = this._editableHandles.get(id);
const kind = editable.radius ? "circle" : "marker";
@@ -990,30 +1065,35 @@ export class HaMap extends ReactiveElement {
}
if (kind === "circle") {
const handle = editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title,
nativeTitle: !tooltip,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: editable.activatable
? () => fireEvent(this, "editable-location-clicked", { id })
: undefined,
});
this._editableHandles.set(id, {
kind,
source: editable,
handle: editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: editable.activatable
? () => fireEvent(this, "editable-location-clicked", { id })
: undefined,
}),
handle,
cleanup: tooltip
? this._attachTooltip(editable.element!, editable.title!)
: undefined,
});
continue;
}
@@ -1052,25 +1132,42 @@ export class HaMap extends ReactiveElement {
}
// A location that cannot be dragged is static on any engine
const support = editable.locationEditable ? editing : staticSupport;
const handle = support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
focusable: !!editable.activatable,
title,
nativeTitle: !tooltip,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
});
const removeTooltip = tooltip
? this._attachTooltip(editable.element!, editable.title!)
: undefined;
this._editableHandles.set(id, {
kind,
source: editable,
cleanup,
handle: support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
focusable: !!editable.activatable,
title,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
}),
cleanup: () => {
cleanup?.();
removeTooltip?.();
},
handle,
});
}
return changed;
}
private _attachTooltip(element: HTMLElement, title: string): () => void {
element.id ||= `ha-map-editable-${this._tooltipCount++}`;
const tooltip = document.createElement("ha-tooltip");
tooltip.for = element.id;
tooltip.textContent = title;
this.shadowRoot!.append(tooltip);
return () => tooltip.remove();
}
// One by one, so the listeners on the caller's elements are detached too
private _removeEditableLocations(): void {
for (const entry of this._editableHandles.values()) {
@@ -1108,11 +1205,9 @@ export class HaMap extends ReactiveElement {
if (!this._i18n || !this._config || !this._engine) {
return;
}
if (this._pathHandles.length) {
this._pathHandles.forEach((handle) => handle.remove());
this._pathHandles = [];
}
if (!this.paths) {
const paths = this.paths ?? [];
this._pathHandles.splice(paths.length).forEach((handle) => handle.remove());
if (!paths.length) {
return;
}
@@ -1120,7 +1215,7 @@ export class HaMap extends ReactiveElement {
"--dark-primary-color"
);
this.paths.forEach((path) => {
paths.forEach((path, index) => {
let opacityStep: number;
let baseOpacity: number;
if (path.gradualOpacity) {
@@ -1212,7 +1307,12 @@ export class HaMap extends ReactiveElement {
segments,
markers,
};
this._pathHandles.push(this._engine!.addPath(enginePath));
const handle = this._pathHandles[index];
if (handle) {
handle.update(enginePath);
} else {
this._pathHandles.push(this._engine!.addPath(enginePath));
}
});
}
@@ -1231,8 +1331,10 @@ export class HaMap extends ReactiveElement {
this._zoneHandles.forEach((handle) => handle.remove());
this._zoneHandles = [];
this._focusZonePoints = [];
let zoneCircleCount = 0;
if (!this.entities) {
this._zoneCircleHandles.splice(0).forEach((handle) => handle.remove());
this._entityMarkers.clear();
this._clusterAvatars.clear();
engine.setClustering(null);
@@ -1314,9 +1416,16 @@ export class HaMap extends ReactiveElement {
);
if (!hideRadius && radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: markerColor })
);
const circleOptions = { radius, color: markerColor };
const circle = this._zoneCircleHandles[zoneCircleCount];
if (circle) {
circle.update(position, circleOptions);
} else {
this._zoneCircleHandles.push(
engine.addCircle(position, circleOptions)
);
}
zoneCircleCount++;
}
const circleEl = createZoneMarkerElement({
@@ -1349,10 +1458,7 @@ export class HaMap extends ReactiveElement {
})
);
if (
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (typeof entity === "string" || entity.focus !== false) {
if (!hideRadius && radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
@@ -1410,6 +1516,7 @@ export class HaMap extends ReactiveElement {
entityMarker.entityColor = entityColor;
entityMarker.selected =
typeof entity !== "string" && (entity.selected ?? false);
entityMarker.floating = true;
const clusterData: ClusterData = {
entityId,
@@ -1427,18 +1534,28 @@ export class HaMap extends ReactiveElement {
: undefined,
};
const accuracy =
gpsAccuracy || (entityMarker.selected ? NOMINAL_ACCURACY : 0);
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
accuracy > 0 && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
...floatingMarkerFootprint(
this._getMarkerSize(computedStyles),
entityMarker.selected
),
title,
cluster: true,
// Selected, it leaves its bubble
cluster: !entityMarker.selected,
raised: entityMarker.selected,
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
? {
radius: accuracy,
color: entityColor,
outline: accuracy > PRECISE_GPS_ACCURACY,
}
: undefined,
})
);
@@ -1448,6 +1565,10 @@ export class HaMap extends ReactiveElement {
}
}
this._zoneCircleHandles
.splice(zoneCircleCount)
.forEach((handle) => handle.remove());
const shownIds = new Set(this.entities.map(getEntityId));
for (const cache of [this._entityMarkers, this._clusterAvatars]) {
for (const entityId of cache.keys()) {
@@ -1493,7 +1614,10 @@ export class HaMap extends ReactiveElement {
expanded = false
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = expanded ? data : data.slice(0, CLUSTER_MAX_AVATARS);
const shown =
expanded || data.length <= CLUSTER_MAX_AVATARS
? data
: data.slice(0, CLUSTER_MAX_AVATARS - 1);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
@@ -1564,37 +1688,30 @@ export class HaMap extends ReactiveElement {
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone = !!zonePosition;
let height =
const height =
rows * CLUSTER_AVATAR_SIZE +
(rows - 1) * CLUSTER_BUBBLE_GAP +
2 * CLUSTER_BUBBLE_PADDING;
let root: HTMLElement = bubble;
if (atZone) {
root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
height += CLUSTER_TAIL_HEIGHT;
}
2 * CLUSTER_BUBBLE_PADDING +
CLUSTER_TAIL_HEIGHT;
const root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
// A cluster of one zone's occupants floats above that zone's circle
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
return {
element: root,
size: [width, height],
// Float above the zone circle, tail pointing at it
...(atZone && zonePosition
? {
location: zonePosition,
anchor: [
width / 2,
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
] as [number, number],
}
: {}),
location: zonePosition,
anchor: [
width / 2,
height +
(zonePosition
? ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING
: FLOATING_LIFT),
],
};
};
@@ -1630,12 +1747,24 @@ export class HaMap extends ReactiveElement {
}
#map {
height: 100%;
/* The map arrives in one piece: the container carries the cartography's
own ground, and fades in with the markers once a frame is drawn */
background-color: #f4efe6;
/* Hidden rather than transparent: the controls and markers in here are
not to be clicked or tabbed to before they are on screen */
visibility: hidden;
opacity: 0;
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-in;
/* A cluster bubble and its tail cast a single shadow around their
combined silhouette (drop-shadow on the wrapper), so no shadow seam
appears between the bubble and its tail. */
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
}
#map.drawn {
visibility: visible;
opacity: 1;
}
#map.clickable {
cursor: pointer;
}
@@ -1653,7 +1782,7 @@ export class HaMap extends ReactiveElement {
transition-delay: var(--ha-animation-duration-fast);
}
#map.dark {
background: #090909;
background: #191b2c;
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
@@ -1667,6 +1796,39 @@ export class HaMap extends ReactiveElement {
color: #000000;
--map-filter: invert(0);
}
/* Zoomed out the sky fades out and the globe is left on a transparent
canvas, so what surrounds it is this element. The same sky in both
themes, day or night: a glow where the sphere sits at the widest zoom
out -- MapLibre's own atmosphere carries the edge from there in -- over
a gradient that lightens towards the top. */
#map.space {
--ha-map-space-glow: rgba(255, 255, 255, 0.95);
--ha-map-space-haze: rgba(255, 255, 255, 0.45);
--ha-map-space-fade: rgba(255, 255, 255, 0);
--ha-map-space-high: #dbe8f8;
--ha-map-space-low: #7aa5d3;
background-color: var(--ha-map-space-low);
background-image:
radial-gradient(
circle at 50% 50%,
var(--ha-map-space-glow) 18%,
var(--ha-map-space-haze) 24%,
var(--ha-map-space-fade) 34%
),
linear-gradient(
180deg,
var(--ha-map-space-high) 0%,
var(--ha-map-space-low) 100%
);
background-repeat: no-repeat;
}
#map.space.dark {
--ha-map-space-glow: rgba(126, 158, 224, 0.5);
--ha-map-space-haze: rgba(86, 110, 170, 0.22);
--ha-map-space-fade: rgba(86, 110, 170, 0);
--ha-map-space-high: #141a30;
--ha-map-space-low: #05060f;
}
#map.clickable:active,
#map:active {
cursor: grabbing;
@@ -1716,6 +1878,23 @@ export class HaMap extends ReactiveElement {
.dark .maplibregl-ctrl button .maplibregl-ctrl-icon {
filter: invert(1);
}
/* Not inherited from the page, which may be in the other mode */
.maplibregl-ctrl.maplibregl-ctrl-attrib {
color: #000000;
}
.dark .maplibregl-ctrl.maplibregl-ctrl-attrib {
background-color: rgba(28, 28, 28, 0.6);
color: #ffffff;
}
.dark .maplibregl-ctrl-attrib.maplibregl-compact {
background-color: #1c1c1c;
}
.dark .maplibregl-ctrl-attrib a {
color: rgba(255, 255, 255, 0.85);
}
.dark .maplibregl-ctrl-attrib-button {
filter: invert(1);
}
/* MapLibre's stylesheet, linked into this root, wins on equal specificity */
.maplibregl-popup-content {
padding: 8px !important;
@@ -1788,9 +1967,9 @@ export class HaMap extends ReactiveElement {
border-radius: 14px;
filter: var(--ha-cluster-shadow);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-selected-scale: 1;
--ha-marker-color: transparent;
--ha-marker-border-width: 1px;
--ha-marker-shadow: none;
--ha-marker-font-size: var(--ha-font-size-s);
/* distinguish letter tiles from the bubble background */
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
+41 -2
View File
@@ -1,11 +1,12 @@
import { ContextProvider } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../common/decorators/consume";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import type { Trigger, TriggerCondition } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import type { ConditionDescriptions } from "../../data/condition";
@@ -28,10 +29,16 @@ import type {
} from "../../data/trace";
import type { TargetSelector } from "../../data/selector";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import { getTraceTriggers } from "../../data/trace-tree";
import type { TriggerDescriptions } from "../../data/trigger";
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 {
automationTriggerContext,
getTriggerIdOptions,
} from "../../panels/config/automation/trigger/automation-trigger-id";
import "../../panels/config/automation/trigger/ha-automation-trigger-references";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
@@ -96,6 +103,26 @@ export class HaTracePathDetails extends LitElement {
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
private _triggerProvider = new ContextProvider(this, {
context: automationTriggerContext,
initialValue: {
options: [],
showIndices: false,
select: () => undefined,
fixDuplicateIds: async () => undefined,
},
});
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("trace")) {
this._triggerProvider.setValue({
...this._triggerProvider.value,
options: getTriggerIdOptions(getTraceTriggers(this.trace.config)),
});
}
}
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
@@ -353,6 +380,7 @@ export class HaTracePathDetails extends LitElement {
return html`<div class="heading">
<h2>${description}</h2>
${this._renderTriggerReferences(currentDetail)}
${this._renderTargets(currentDetail, selectedType)}
</div>`;
}
@@ -366,10 +394,21 @@ export class HaTracePathDetails extends LitElement {
return html`<div class="nested-condition">
${describeCondition(currentDetail, this.hass, this._entityReg)}
${this._renderTriggerReferences(currentDetail)}
${this._renderTargets(currentDetail, "condition", "s")}
</div>`;
}
private _renderTriggerReferences(config: any) {
if (config?.condition !== "trigger") {
return nothing;
}
return html`<ha-automation-trigger-references
.condition=${config as TriggerCondition}
.hass=${this.hass}
></ha-automation-trigger-references>`;
}
private _renderTargets(
config: any,
type: NodeInfo["type"],
+17 -7
View File
@@ -29,6 +29,8 @@ export class HatGraphBranch extends LitElement {
@property({ type: Boolean }) short = false;
@property({ type: Boolean }) end = false;
@state() _branches: BranchConfig[] = [];
private _totalWidth = 0;
@@ -144,10 +146,16 @@ export class HatGraphBranch extends LitElement {
: nothing
}
<div id="branches">
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
${this._branches.map((branch) => {
if (branch.end) return "";
return svg`
${
!this.end
? html`<svg
id="lines"
width=${this._totalWidth}
height=${this._maxHeight}
>
${this._branches.map((branch) => {
if (branch.end) return "";
return svg`
<path
class=${classMap({
track: branch.trackEnd,
@@ -157,13 +165,15 @@ export class HatGraphBranch extends LitElement {
v ${this._maxHeight - branch.height}
"/>
`;
})}
</svg>
})}
</svg>`
: nothing
}
<slot @slotchange=${this._updateBranches}></slot>
</div>
${
!this.short
!this.short && !this.end
? html`
<svg id="bottom" width=${this._totalWidth}>
${this._branches.map((branch) => {
+26 -27
View File
@@ -3,6 +3,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing, svg } from "lit";
import { customElement, property } from "lit/decorators";
import { isSafari } from "../../util/is_safari";
import "../ha-svg-icon";
import {
BUILDING_BLOCK_ICON_SIZE,
BUILDING_BLOCK_RADIUS,
@@ -66,17 +67,17 @@ export class HatGraphNode extends LitElement {
const width = SPACING + NODE_SIZE;
const size = this.buildingBlock ? BUILDING_BLOCK_SIZE : NODE_SIZE;
const iconSize = this.buildingBlock ? BUILDING_BLOCK_ICON_SIZE : ICON_SIZE;
// Distance from the top of the svg to the node center (viewBox origin).
const centerY = this.graphStart
? Math.ceil(height / 2)
: Math.ceil((NODE_SIZE + SPACING * 2) / 2);
// A rotated building block is wider than its side.
return html`
<svg
class=${isSafari ? "safari" : ""}
width="${width}px"
height="${height}px"
viewBox="-${Math.ceil(width / 2)} -${
this.graphStart
? Math.ceil(height / 2)
: Math.ceil((NODE_SIZE + SPACING * 2) / 2)
} ${width} ${height}"
viewBox="-${Math.ceil(width / 2)} -${centerY} ${width} ${height}"
>
${
this.graphStart
@@ -134,19 +135,6 @@ export class HatGraphNode extends LitElement {
`
: nothing
}
<g
class="icon-wrapper"
style="pointer-events: none"
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
iconSize / ICON_SIZE
})"
>
${
this.iconPath
? svg`<path class="icon" d=${this.iconPath}/>`
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
}
</g>
${
this.notEnabled
? svg`
@@ -162,6 +150,15 @@ export class HatGraphNode extends LitElement {
}
</g>
</svg>
<span class="icon" style="top: ${centerY}px">
<slot name="icon">
${
this.iconPath
? html`<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>`
: nothing
}
</slot>
</span>
`;
}
@@ -169,12 +166,12 @@ export class HatGraphNode extends LitElement {
:host {
display: flex;
flex-direction: column;
position: relative;
min-width: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing));
height: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px);
}
/* The count badge overlaps the next node's connector. */
:host([badge]) {
position: relative;
z-index: 1;
}
:host([graph-start]) {
@@ -219,7 +216,7 @@ export class HatGraphNode extends LitElement {
/* Not the whole node group: SVG composites group opacity as a unit, which
would fade the error and count badges along with the rest. The body is
left at full strength so the grey does not wash out. */
:host([not-enabled]) .icon-wrapper,
:host([not-enabled]) .icon,
:host([not-enabled]) .strike {
opacity: 0.6;
}
@@ -279,15 +276,17 @@ export class HatGraphNode extends LitElement {
font-size: var(--ha-font-size-s);
fill: var(--text-primary-color);
}
path.icon {
fill: var(--icon-clr);
}
foreignObject {
width: 24px;
height: 24px;
}
.icon {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
pointer-events: none;
color: var(--icon-clr);
--mdc-icon-size: 24px;
}
:host([building-block]) .icon {
--mdc-icon-size: 20px;
}
`;
}
+89 -49
View File
@@ -139,22 +139,40 @@ export class HatScriptGraph extends LitElement {
other: this._renderOtherNode,
};
private _renderActionNode(node: TraceActionNode, graphStart = false) {
private _renderActionNode(
node: TraceActionNode,
graphStart = false,
graphEnd = false
) {
const type = node.actionType ?? "other";
return (this._typeRenderers[type] ?? this._renderOtherNode).bind(this)(
node,
graphStart
graphStart,
graphEnd
);
}
// Only the last action of a branch in a block that ends the graph ends it.
private _renderBranchActions(actions: TraceActionNode[], graphEnd: boolean) {
return actions.map((action, i) =>
this._renderActionNode(
action,
false,
graphEnd && i === actions.length - 1
)
);
}
private _renderChooseNode(
node: TraceActionNode<ChooseAction>,
graphStart = false
graphStart = false,
graphEnd = false
) {
const { config, path, track } = node;
const defaultBranch = node.branches[node.branches.length - 1];
return html`
<hat-graph-branch
.end=${graphEnd}
tabindex=${node.hasTrace ? "0" : "-1"}
@focus=${this._selectNode(config, path, "action")}
?track=${track}
@@ -203,31 +221,39 @@ export class HatScriptGraph extends LitElement {
aria-label=${ifDefined(this.labels?.[branch.path])}
aria-current=${ifDefined(this.selected === branch.path || undefined)}
></hat-graph-node>
${branch.children.map((action) => this._renderActionNode(action))}
${this._renderBranchActions(branch.children, graphEnd)}
</div>
`
)}
<div
?track=${defaultBranch.hasTrace}
?unfinished=${defaultBranch.unfinished}
>
<hat-graph-spacer
aria-hidden="true"
?track=${defaultBranch.hasTrace}
></hat-graph-spacer>
${defaultBranch.children.map((action) =>
this._renderActionNode(action)
)}
</div>
${
// An empty default branch leads nowhere when nothing follows.
graphEnd && !defaultBranch.children.length
? nothing
: html`<div
?track=${defaultBranch.hasTrace}
?unfinished=${defaultBranch.unfinished}
>
<hat-graph-spacer
aria-hidden="true"
?track=${defaultBranch.hasTrace}
></hat-graph-spacer>
${this._renderBranchActions(defaultBranch.children, graphEnd)}
</div>`
}
</hat-graph-branch>
`;
}
private _renderIfNode(node: TraceActionNode<IfAction>, graphStart = false) {
private _renderIfNode(
node: TraceActionNode<IfAction>,
graphStart = false,
graphEnd = false
) {
const { config, path, track } = node;
const [thenBranch, elseBranch] = node.branches;
return html`
<hat-graph-branch
.end=${graphEnd}
tabindex=${node.hasTrace ? "0" : "-1"}
@focus=${this._selectNode(config, path, "action")}
?track=${track}
@@ -250,6 +276,21 @@ export class HatScriptGraph extends LitElement {
nofocus
aria-hidden="true"
></hat-graph-node>
<div
class="graph-container"
?track=${thenBranch.hasTrace}
?unfinished=${thenBranch.unfinished}
>
<hat-graph-node
.iconPath=${mdiCallReceived}
?track=${thenBranch.hasTrace}
?active=${this.selected === path}
.notEnabled=${thenBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node>
${this._renderBranchActions(thenBranch.children, graphEnd)}
</div>
${
config.else
? html`<div
@@ -265,30 +306,15 @@ export class HatScriptGraph extends LitElement {
nofocus
aria-hidden="true"
></hat-graph-node
>${elseBranch.children.map((action) =>
this._renderActionNode(action)
)}
>${this._renderBranchActions(elseBranch.children, graphEnd)}
</div>`
: html`<hat-graph-spacer
aria-hidden="true"
?track=${elseBranch.hasTrace}
></hat-graph-spacer>`
: graphEnd
? nothing
: html`<hat-graph-spacer
aria-hidden="true"
?track=${elseBranch.hasTrace}
></hat-graph-spacer>`
}
<div
class="graph-container"
?track=${thenBranch.hasTrace}
?unfinished=${thenBranch.unfinished}
>
<hat-graph-node
.iconPath=${mdiCallReceived}
?track=${thenBranch.hasTrace}
?active=${this.selected === path}
.notEnabled=${thenBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node>
${thenBranch.children.map((action) => this._renderActionNode(action))}
</div>
</hat-graph-branch>
`;
}
@@ -350,12 +376,14 @@ export class HatScriptGraph extends LitElement {
private _renderRepeatNode(
model: TraceActionNode<RepeatAction>,
graphStart = false
graphStart = false,
graphEnd = false
) {
const { config: node, path, track } = model;
const [branch] = model.branches;
return html`
<hat-graph-branch
.end=${graphEnd}
tabindex=${model.hasTrace ? "0" : "-1"}
@focus=${this._selectNode(node, path, "action")}
?track=${track}
@@ -384,7 +412,7 @@ export class HatScriptGraph extends LitElement {
?track=${branch.hasTrace}
?unfinished=${branch.unfinished}
>
${branch.children.map((action) => this._renderActionNode(action))}
${this._renderBranchActions(branch.children, graphEnd)}
</div>
</hat-graph-branch>
`;
@@ -455,12 +483,14 @@ export class HatScriptGraph extends LitElement {
private _renderSequenceNode(
model: TraceActionNode<SequenceAction>,
graphStart = false
graphStart = false,
graphEnd = false
) {
const { config: node, path, track } = model;
const [branch] = model.branches;
return html`
<hat-graph-branch
.end=${graphEnd}
tabindex=${model.hasTrace ? "0" : "-1"}
@focus=${this._selectNode(node, path, "action")}
?track=${track}
@@ -488,7 +518,7 @@ export class HatScriptGraph extends LitElement {
nofocus
aria-hidden="true"
></hat-graph-node>
${branch.children.map((action) => this._renderActionNode(action))}
${this._renderBranchActions(branch.children, graphEnd)}
</div>
</hat-graph-branch>
`;
@@ -496,11 +526,13 @@ export class HatScriptGraph extends LitElement {
private _renderParallelNode(
model: TraceActionNode<ParallelAction>,
graphStart = false
graphStart = false,
graphEnd = false
) {
const { config: node, path, track } = model;
return html`
<hat-graph-branch
.end=${graphEnd}
tabindex=${model.hasTrace ? "0" : "-1"}
@focus=${this._selectNode(node, path, "action")}
?track=${track}
@@ -529,9 +561,7 @@ export class HatScriptGraph extends LitElement {
?track=${branch.hasTrace}
?unfinished=${branch.unfinished}
>
${branch.children.map((sAction) =>
this._renderActionNode(sAction)
)}
${this._renderBranchActions(branch.children, graphEnd)}
</div>`
)}
</hat-graph-branch>
@@ -577,9 +607,19 @@ export class HatScriptGraph extends LitElement {
: ""
}
${tree.conditions.map((node) => this._renderConditionNode(node))}
${tree.actions.map((node) => this._renderActionNode(node))}
${tree.actions.map((node, i) =>
this._renderActionNode(
node,
false,
!tree.sequence.length && i === tree.actions.length - 1
)
)}
${tree.sequence.map((node, i) =>
this._renderActionNode(node, i === 0)
this._renderActionNode(
node,
i === 0,
i === tree.sequence.length - 1
)
)}
</div>
</div>
+11 -1
View File
@@ -362,11 +362,17 @@ export type ConditionWithShorthand =
| ShorthandAndConditionList
| ShorthandAndCondition
| ShorthandOrCondition
| ShorthandNotCondition;
| ShorthandNotCondition
| string;
export const expandConditionWithShorthand = (
cond: ConditionWithShorthand
): Condition => {
// A bare template string is shorthand for a template condition
if (typeof cond === "string") {
return { condition: "template", value_template: cond };
}
if ("condition" in cond && Array.isArray(cond.condition)) {
return {
condition: "and",
@@ -573,6 +579,10 @@ export const flattenTriggers = (
const flatTriggers: Trigger[] = [];
ensureArray(triggers).forEach((t) => {
// Incomplete YAML can leave null entries in the trigger list
if (!t || typeof t !== "object") {
return;
}
if ("triggers" in t) {
if (t.triggers) {
flatTriggers.push(...flattenTriggers(t.triggers));
+1 -15
View File
@@ -149,8 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Rows with trigger-reference chips render the IDs separately.
hideTriggerIds?: boolean;
}
export const describeTrigger = (
@@ -925,7 +923,7 @@ const tryDescribeCondition = (
return condition.alias;
}
if (condition.condition === "trigger" && options?.hideTriggerIds) {
if (condition.condition === "trigger") {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.summary`
);
@@ -1305,17 +1303,5 @@ const describeLegacyCondition = (
);
}
if (condition.condition === "trigger" && condition.id != null) {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.full`,
{
id: formatListWithOrs(
hass.locale,
ensureArray(condition.id).map((id) => id.toString())
),
}
);
}
return undefined;
};
+31 -1
View File
@@ -7,6 +7,7 @@ import {
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { LocalizeKeys } from "../common/translations/localize";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
import type { SerialPortUsage } from "./usb";
@@ -178,6 +179,7 @@ export const deriveESPHomeSetupStatus = (
sendspinSupported?: boolean;
sendspinEnabled?: boolean;
serialConfigured?: boolean;
zwaveFlowInProgress?: boolean;
}
): ESPHomeSetupStatus => {
const status: ESPHomeSetupStatus = {};
@@ -199,7 +201,10 @@ export const deriveESPHomeSetupStatus = (
if (capabilities.zwave_proxy.supported) {
if (capabilities.zwave_proxy.config_entry_id) {
status.connectivity = "completed";
} else if (capabilities.zwave_proxy.home_id !== 0) {
} else if (
capabilities.zwave_proxy.home_id !== 0 ||
options.zwaveFlowInProgress
) {
status.connectivity = "detected";
} else {
status.connectivity = "not-started";
@@ -217,6 +222,31 @@ export const deriveESPHomeSetupStatus = (
return status;
};
// Core stores the device MAC through format_mac; the discovery key keeps the raw value.
const normalizeMac = (mac: string): string =>
mac.replace(/[^0-9a-f]/gi, "").toLowerCase();
/** The zwave_js discovery flow ESPHome started for this device, keyed by its MAC. */
export const findESPHomeZWaveFlow = (
flows: readonly DataEntryFlowProgress[],
macAddress: string | undefined
): DataEntryFlowProgress | undefined => {
const mac = macAddress ? normalizeMac(macAddress) : undefined;
if (!mac) {
return undefined;
}
return flows.find((flow) => {
const discoveryKey = flow.context.discovery_key;
return (
flow.handler === "zwave_js" &&
flow.context.source === "esphome" &&
discoveryKey?.domain === "esphome" &&
typeof discoveryKey.key === "string" &&
normalizeMac(discoveryKey.key) === mac
);
});
};
export const getESPHomeSetupCapabilityIds = (
status: ESPHomeSetupStatus
): ESPHomeCapabilityId[] =>
+62 -24
View File
@@ -5,8 +5,8 @@ import {
mdiClipboardList,
mdiFormatListBulletedType,
mdiLightningBolt,
mdiMap,
mdiPlayBoxMultiple,
mdiTooltipAccount,
} from "@mdi/js";
import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
@@ -107,22 +107,48 @@ export const getConfigSubpageTitle = (
path: string,
configSections: Record<string, PageNavigation[]>
): string | undefined => {
// Search through all config section groups for a matching path
for (const sectionGroup of Object.values(configSections)) {
const pageNav = sectionGroup.find((nav) => path.startsWith(nav.path));
if (pageNav) {
if (pageNav.translationKey) {
const localized = hass.localize(pageNav.translationKey as LocalizeKeys);
if (localized) {
return localized;
}
}
const sections = Object.entries(configSections);
if (pageNav.name) {
return pageNav.name;
// Prefer pages with a full translation key or a name. The dashboard
// sections also list pages like /config/integrations under a broader title.
for (const [, pages] of sections) {
const pageNav = pages.find((nav) => path.startsWith(nav.path));
if (!pageNav) {
continue;
}
if (pageNav.translationKey?.includes(".")) {
const localized = hass.localize(pageNav.translationKey as LocalizeKeys);
if (localized) {
return localized;
}
}
if (pageNav.name) {
return pageNav.name;
}
}
// Some pages are only listed with a short translation key. Expand it the
// same way their navigation does.
for (const [section, pages] of sections) {
const shortKey = pages.find((nav) =>
path.startsWith(nav.path)
)?.translationKey;
if (!shortKey || shortKey.includes(".")) {
continue;
}
const translationKey =
section === "general"
? `ui.panel.config.${shortKey}.caption`
: `ui.panel.config.dashboard.${shortKey}.main`;
const localized = hass.localize(translationKey as LocalizeKeys);
if (localized) {
return localized;
}
}
return undefined;
};
@@ -137,19 +163,31 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
return panel.icon || undefined;
};
export const PANEL_ICON_PATHS = {
calendar: mdiCalendar,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
map: mdiTooltipAccount,
profile: mdiAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
// Built-in panels render their stock icon from a bundled path, so the sidebar
// does not wait for the icon set to load.
const BUILT_IN_PANEL_ICONS: Record<string, { icon?: string; path: string }> = {
calendar: { icon: "mdi:calendar", path: mdiCalendar },
energy: { icon: "mdi:lightning-bolt", path: mdiLightningBolt },
history: { icon: "mdi:chart-box", path: mdiChartBox },
logbook: {
icon: "mdi:format-list-bulleted-type",
path: mdiFormatListBulletedType,
},
map: { icon: "mdi:map", path: mdiMap },
profile: { path: mdiAccount },
"media-browser": { icon: "mdi:play-box-multiple", path: mdiPlayBoxMultiple },
todo: { icon: "mdi:clipboard-list", path: mdiClipboardList },
};
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const getPanelIconPath = (panel: PanelInfo): string | undefined => {
const builtIn = BUILT_IN_PANEL_ICONS[panel.url_path];
if (!builtIn) {
return undefined;
}
// An icon the user picked for the panel always wins.
return !panel.icon || panel.icon === builtIn.icon ? builtIn.path : undefined;
};
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
+13 -7
View File
@@ -73,6 +73,16 @@ const isDisabled = (config: unknown, parentDisabled: boolean): boolean =>
"enabled" in config &&
(config as { enabled?: boolean }).enabled === false);
/** The automation's triggers, flattened like the trace paths count them. */
export const getTraceTriggers = (
config: TraceExtended["config"]
): Trigger[] | undefined => {
const triggerKey = "triggers" in config ? "triggers" : "trigger";
return triggerKey in config
? flattenTriggers(ensureArray(config[triggerKey]))
: undefined;
};
/** Configuration-shaped tree annotated with the execution tracked by Core. */
export class TraceTree {
public readonly triggers?: TraceNode<Trigger>[];
@@ -97,15 +107,11 @@ export class TraceTree {
constructor(public readonly trace: TraceExtended) {
const config = trace.config;
const triggerKey = "triggers" in config ? "triggers" : "trigger";
const conditionKey = "conditions" in config ? "conditions" : "condition";
const actionKey = "actions" in config ? "actions" : "action";
this.triggers =
triggerKey in config
? flattenTriggers(ensureArray(config[triggerKey])).map((trigger, i) =>
this._triggerNode(trigger, `trigger/${i}`)
)
: undefined;
this.triggers = getTraceTriggers(config)?.map((trigger, i) =>
this._triggerNode(trigger, `trigger/${i}`)
);
this.conditions =
conditionKey in config
? ensureArray<Condition>(config[conditionKey] ?? []).map(
+4 -1
View File
@@ -227,7 +227,10 @@ export const reconfigureNode = (
{
type: "zha/devices/reconfigure",
ieee: ieeeAddress,
}
},
// Subscribing starts the reconfiguration, so a reconnect must not
// start it again.
{ resubscribe: false }
);
export const refreshTopology = (hass: HomeAssistant): Promise<void> =>
@@ -175,7 +175,7 @@ export const showConfigFlowDialog = (
renderExternalStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || step.handler}.config.${step.step_id}.description`,
`component.${step.translation_domain || step.handler}.config.step.${step.step_id}.description`,
step.description_placeholders
);
@@ -30,6 +30,7 @@ import {
configContext,
configEntriesContext,
connectionContext,
devicesContext,
entitiesContext,
internationalizationContext,
statesContext,
@@ -46,6 +47,7 @@ import {
ESPHOME_CAPABILITY_ACCENTS,
ESPHOME_CAPABILITY_ICONS,
ESPHOME_CAPABILITY_TITLE_KEYS,
findESPHomeZWaveFlow,
getESPHomeAudioControls,
getESPHomeSetupCapabilityIds,
isESPHomeSerialConfigured,
@@ -65,7 +67,7 @@ import { extractApiErrorMessage } from "../../data/hassio/common";
import { listSerialPortsWithUsage, type SerialPortUsage } from "../../data/usb";
import { showAddIntegrationDialog } from "../../panels/config/integrations/show-add-integration-dialog";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistantUI } from "../../types";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { getWsErrorMessage } from "../../util/ws-error";
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
@@ -159,6 +161,20 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
@consume({ context: configEntriesContext, subscribe: true })
private _configEntries?: ContextType<typeof configEntriesContext>;
@state()
@consume({ context: devicesContext, subscribe: true })
@transform<HomeAssistant["devices"], string | undefined>({
transformer: function (this: DialogESPHomeDeviceSetup, devices) {
const deviceId = this.params?.deviceId;
return deviceId
? devices[deviceId]?.connections.find(([type]) => type === "mac")?.[1]
: undefined;
},
})
private _macAddress?: string;
@state() private _zwaveFlows: DataEntryFlowProgress[] = [];
@state() private _capabilities?: ESPHomeDeviceCapabilities;
/** Last successful USB usage scan. Undefined until a scan completes. */
@@ -662,21 +678,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return html`
<p>${localize("ui.panel.config.devices.esphome.setup_adapters_intro")}</p>
<ha-list-nav>
<ha-list-item-button
href=${documentationUrl(this._hassConfig!, "/connect/zbt-2/")}
target="_blank"
rel="noreferrer noopener"
>
<span slot="headline">
${localize("ui.panel.config.devices.esphome.setup_adapter_zbt2")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.config.devices.esphome.setup_adapter_zbt2_description"
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
href=${documentationUrl(this._hassConfig!, "/connect/zwa-2/")}
target="_blank"
@@ -738,6 +739,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
this._capabilities.serial_proxies,
this._serialPorts
),
zwaveFlowInProgress:
findESPHomeZWaveFlow(this._zwaveFlows, this._macAddress) !== undefined,
});
// A failed scan left no usage data. Keep the row, but do not show
// Configured or Set up from that missing result.
@@ -766,6 +769,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return;
}
this._capabilities = capabilities;
if (capabilities.zwave_proxy.supported) {
await this._refreshZWaveFlows();
if (!this.isConnected) {
return;
}
}
await this._refreshSerialPorts();
if (!this.isConnected) {
return;
@@ -1098,12 +1107,25 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
});
}
private async _refreshZWaveFlows() {
if (!this._connection) {
return;
}
try {
const flows = await fetchConfigFlowInProgress(
this._connection.connection
);
this._zwaveFlows = flows.filter((flow) => flow.handler === "zwave_js");
} catch {
// Keep the last list.
}
}
private async _setupZWave(ev: Event) {
ev.stopPropagation();
if (!this._connection || !this._capabilities || !this._i18n) {
return;
}
const homeId = String(this._capabilities.zwave_proxy.home_id);
let flows: DataEntryFlowProgress[];
try {
flows = await fetchConfigFlowInProgress(this._connection.connection);
@@ -1118,12 +1140,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
if (!this.isConnected) {
return;
}
const flow = flows.find(
(item) =>
item.handler === "zwave_js" &&
item.context?.source === "esphome" &&
item.context?.unique_id === homeId
);
const flow = findESPHomeZWaveFlow(flows, this._macAddress);
if (!flow) {
this._error = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_error_zwave"
+12 -1
View File
@@ -90,7 +90,7 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
type=${confirmPrompt ? "alert" : "standard"}
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
@closed=${this._dialogClosed}
aria-labelledby="dialog-box-title"
.accessibleLabel=${this._accessibleLabel(dialogTitle)}
aria-describedby="dialog-box-description"
>
<ha-dialog-header slot="header">
@@ -217,6 +217,17 @@ class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
}
}
// Alerts often have no title. Their message is what a screen reader should
// announce then, as long as it is plain text.
private _accessibleLabel(dialogTitle: string | false | undefined) {
if (dialogTitle) {
return dialogTitle;
}
return typeof this._params?.text === "string"
? this._params.text
: undefined;
}
private _dismiss(): void {
this._closeState = "canceled";
this._cancel();
@@ -152,6 +152,11 @@ export class MoreInfoInfo extends LitElement {
margin-bottom: var(--ha-space-4);
}
ha-more-info-history,
ha-more-info-logbook {
margin-inline: calc(var(--ha-space-6) * -1);
}
ha-alert {
display: block;
}
+7 -1
View File
@@ -35,7 +35,7 @@ import {
} from "../../data/hassio/host";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant, ServiceCallRequest } from "../../types";
import { showToast } from "../../util/toast";
import { HOST_ACTION_TOAST_ID, showToast } from "../../util/toast";
import {
showAlertDialog,
showConfirmationDialog,
@@ -315,6 +315,7 @@ class DialogRestart extends LitElement {
private _hostAction =
(toastMessage: string, action: "reboot" | "shutdown") => async () => {
showToast(this, {
id: HOST_ACTION_TOAST_ID,
message: toastMessage,
duration: -1,
});
@@ -328,6 +329,11 @@ class DialogRestart extends LitElement {
} catch (err: any) {
// Ignore connection errors, these are all expected
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
showToast(this, {
id: HOST_ACTION_TOAST_ID,
message: "",
duration: 0,
});
showAlertDialog(this, {
title: this.hass.localize(`ui.dialogs.restart.${action}.failed`),
text: extractApiErrorMessage(err),
@@ -14,6 +14,7 @@ import {
listAssistPipelines,
} from "../../data/assist_pipeline";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import type { CloudStatus } from "../../data/cloud";
import { fetchCloudStatus } from "../../data/cloud";
import type { LanguageScore, LanguageScores } from "../../data/conversation";
import { getLanguageScores, listAgents } from "../../data/conversation";
@@ -257,7 +258,14 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
if (!isComponentLoaded(this.hass.config, "cloud")) {
return false;
}
const cloudStatus = await fetchCloudStatus(this.hass);
// Fall back to a local pipeline when the cloud status can't be fetched,
// instead of leaving the step blank.
let cloudStatus: CloudStatus;
try {
cloudStatus = await fetchCloudStatus(this.hass);
} catch (_err: any) {
return false;
}
if (!cloudStatus.logged_in || !cloudStatus.active_subscription) {
return false;
}
+60 -26
View File
@@ -77,22 +77,34 @@ const handleProxyRequest: RouteHandler = async ({ request }) => {
return fetch(req);
};
// The access token rotates, so keying on the full URL empties the cache every
// rotation. Other params (such as brands' "placeholder") stay in the key.
const ignoreTokenPlugin = {
cacheKeyWillBeUsed: async ({ request }: { request: Request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
ignoreURLParametersMatching: [/.*/],
});
// Cache static content (including translations) on first access.
// Cache static content (including translations) on first access. Match on
// the start of the path: custom integrations serve their own files from
// paths like /api/<domain>/static/, and those must not be cached forever.
registerRoute(
/\/(static|frontend_latest|frontend_es5)\/.+/,
({ url }) =>
url.origin === location.origin &&
/^\/(static|frontend_latest|frontend_es5)\/.+/.test(url.pathname),
new CacheFirst({ matchOptions: { ignoreSearch: true } })
);
// Cache any brand images used for 1 day
// Brands are proxied via the local API with backend caching.
// Strip the rotating access token from cache keys so token rotation
// doesn't bust the cache, while preserving other params like "placeholder".
registerRoute(
({ url, request }) =>
url.pathname.startsWith("/api/brands/") &&
@@ -100,13 +112,7 @@ const initRouting = () => {
new StaleWhileRevalidate({
cacheName: "brands",
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
ignoreTokenPlugin,
// Add 404 so we quickly respond to domains with missing images
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
new ExpirationPlugin({
@@ -117,26 +123,37 @@ const initRouting = () => {
})
);
// Strip the rotating token from the cache key, or every rotation refetches
// every tile. The TileJSON is deliberately not matched: it is the switching
// point for the tile source, so it stays on the network.
// A stale token gives a 403, and caching that would pin the failure.
const cacheableTile = new CacheableResponsePlugin({ statuses: [0, 200] });
// A couple of dozen, pinned to an upstream release. Kept apart from the
// tiles so panning cannot evict the fonts every label needs.
registerRoute(
({ url }) =>
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
({ url }) => /^\/api\/map_tiles\/(fonts|sprites)\//.test(url.pathname),
new CacheFirst({
cacheName: "map-assets",
plugins: [
ignoreTokenPlugin,
cacheableTile,
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true,
}),
],
})
);
// The working set is a tile pyramid over every zoom visited, so the ceiling
// is generous; the quota purge is what actually bounds it.
registerRoute(
({ url }) => /^\/api\/map_tiles\/(vector|raster)\//.test(url.pathname),
new CacheFirst({
cacheName: "map-tiles",
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// A stale token gives a 403; caching that would pin the failure.
new CacheableResponsePlugin({ statuses: [0, 200] }),
ignoreTokenPlugin,
cacheableTile,
new ExpirationPlugin({
maxEntries: 500,
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 7,
purgeOnQuotaError: true,
}),
@@ -144,6 +161,23 @@ const initRouting = () => {
})
);
// Every map waits on this before its first tile, so serve it from cache -
// revalidated behind that, since it is how a moved endpoint arrives.
registerRoute(
({ url }) => url.pathname === "/api/map_tiles/tilejson.json",
new StaleWhileRevalidate({
cacheName: "map-tilejson",
plugins: [
ignoreTokenPlugin,
cacheableTile,
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24,
purgeOnQuotaError: true,
}),
],
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,36 @@
import { MockToggleEntity } from "./toggle-entity";
import type { EntityAttributes } from "./types";
const AUTOMATION_ATTRIBUTES = [
"id",
"last_triggered",
"mode",
"current",
"max",
];
export class MockAutomationEntity extends MockToggleEntity {
public async handleService(
domain: string,
service: string,
data: Record<string, any>
): Promise<void> {
if (domain === "automation" && service === "trigger") {
this.update({
attributes: { last_triggered: new Date().toISOString() },
});
return;
}
super.handleService(domain, service, data);
}
protected _getStateAttributes(): EntityAttributes {
const stateAttrs: EntityAttributes = {};
for (const key of AUTOMATION_ATTRIBUTES) {
if (key in this.attributes) {
stateAttrs[key] = this.attributes[key];
}
}
return stateAttrs;
}
}
+2 -2
View File
@@ -56,8 +56,8 @@ export class MockBaseEntity {
this.areaId = input.area_id;
this.deviceId = input.device_id;
this.platform = input.platform;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
this.lastChanged = input.last_changed ?? randomTime();
this.lastUpdated = input.last_changed ?? randomTime();
const attributes: EntityAttributes = input.attributes || {};
+27
View File
@@ -0,0 +1,27 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityAttributes } from "./types";
const LOCATION_ATTRIBUTES = [
"latitude",
"longitude",
"gps_accuracy",
"radius",
"passive",
"persons",
"editable",
"source",
"source_type",
"in_zones",
];
export class MockLocationEntity extends MockBaseEntity {
protected _getStateAttributes(): EntityAttributes {
const stateAttrs: EntityAttributes = {};
for (const key of LOCATION_ATTRIBUTES) {
if (key in this.attributes) {
stateAttrs[key] = this.attributes[key];
}
}
return stateAttrs;
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityInput } from "./types";
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
import { MockAutomationEntity } from "./automation-entity";
import { MockClimateEntity } from "./climate-entity";
import { MockCoverEntity } from "./cover-entity";
import { MockFanEntity } from "./fan-entity";
@@ -11,6 +12,7 @@ import { MockLawnMowerEntity } from "./lawn-mower-entity";
import { MockInputSelectEntity } from "./input-select-entity";
import { MockInputTextEntity } from "./input-text-entity";
import { MockLightEntity } from "./light-entity";
import { MockLocationEntity } from "./location-entity";
import { MockLockEntity } from "./lock-entity";
import { MockMediaPlayerEntity } from "./media-player-entity";
import { MockToggleEntity } from "./toggle-entity";
@@ -21,10 +23,11 @@ import { MockWaterHeaterEntity } from "./water-heater-entity";
type EntityConstructor = new (input: EntityInput) => MockBaseEntity;
const TYPES: Record<string, EntityConstructor> = {
automation: MockToggleEntity,
automation: MockAutomationEntity,
alarm_control_panel: MockAlarmControlPanelEntity,
climate: MockClimateEntity,
cover: MockCoverEntity,
device_tracker: MockLocationEntity,
fan: MockFanEntity,
group: MockGroupEntity,
humidifier: MockHumidifierEntity,
@@ -36,10 +39,12 @@ const TYPES: Record<string, EntityConstructor> = {
light: MockLightEntity,
lock: MockLockEntity,
media_player: MockMediaPlayerEntity,
person: MockLocationEntity,
switch: MockToggleEntity,
vacuum: MockVacuumEntity,
valve: MockValveEntity,
water_heater: MockWaterHeaterEntity,
zone: MockLocationEntity,
};
export const getEntity = (input: EntityInput): MockBaseEntity => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { MockBaseEntity } from "./base-entity";
/**
* Generic toggle entity used for domains: automation, input_boolean, switch.
* Generic toggle entity used for domains: input_boolean, switch.
* Handles turn_on, turn_off, and toggle services via homeassistant or own domain.
*/
export class MockToggleEntity extends MockBaseEntity {
+2
View File
@@ -17,6 +17,8 @@ export type EntityInput = Pick<
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
/** When the state last changed, a random recent time by default */
last_changed?: string;
};
/**
+76 -9
View File
@@ -363,6 +363,11 @@ export class HAFullCalendar extends LitElement {
config.dateClick = (info) => this._handleDateClick(info);
config.eventClick = (info) => this._handleEventClick(info);
// fullcalendar sets the event colors only inline, where styles cannot mix them
config.eventDidMount = ({ el, event }) => {
el.style.setProperty("--event-color", event.borderColor);
el.style.setProperty("--event-text-color", event.textColor);
};
this.calendar = new Calendar(
this.shadowRoot!.getElementById("calendar")!,
@@ -768,30 +773,92 @@ export class HAFullCalendar extends LitElement {
padding: 0 1px;
}
.tentative {
--tentative-tint: color-mix(
in srgb,
var(--event-color) 25%,
transparent
);
/* In the text color, so the stripes show on every calendar color */
--tentative-stripe: color-mix(
in srgb,
var(--primary-text-color) 12%,
transparent
);
}
/* A dot keeps its size and color and gets stripes in the color that
contrasts with it, 1.5px wide every 4px */
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
--tentative-dot-stripe: color-mix(
in srgb,
var(--event-text-color, #fff) 80%,
transparent
);
border: none;
border-radius: 50%;
background-color: var(--event-color);
background-image: linear-gradient(
45deg,
var(--tentative-dot-stripe) 0 9.375%,
transparent 9.375% 40.625%,
var(--tentative-dot-stripe) 40.625% 59.375%,
transparent 59.375% 90.625%,
var(--tentative-dot-stripe) 90.625%
);
background-size: 4px 4px;
/* Half a tile, so no stripe runs through the middle like a slash */
background-position: 2px 0;
}
.tentative .fc-daygrid-event-dot {
/* A long title would otherwise squeeze the ring into an oval */
/* A long title would otherwise squeeze the dot into an oval */
flex-shrink: 0;
border-width: 2px;
width: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
height: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
width: var(--fc-daygrid-event-dot-width, 8px);
height: var(--fc-daygrid-event-dot-width, 8px);
}
.tentative .fc-list-event-dot {
border-width: 2px;
width: calc(var(--fc-list-event-dot-width, 10px) - 4px);
height: calc(var(--fc-list-event-dot-width, 10px) - 4px);
width: var(--fc-list-event-dot-width, 10px);
height: var(--fc-list-event-dot-width, 10px);
}
/* fullcalendar sets the colors as inline styles */
.fc-h-event.tentative {
background-color: transparent !important;
border-style: dashed;
background-color: var(--tentative-tint) !important;
/* A tile of whole pixels puts every stripe on the same pixels,
2.8px wide every 14px */
background-image: linear-gradient(
45deg,
var(--tentative-stripe) 0 5%,
transparent 5% 45%,
var(--tentative-stripe) 45% 55%,
transparent 55% 95%,
var(--tentative-stripe) 95%
);
background-size: 14px 14px;
}
.fc-h-event.tentative .fc-event-main {
color: var(--primary-text-color) !important;
}
/* Forced colors drop the stripes and the fill of a dot, so a tentative
event falls back to its outline */
@media (forced-colors: active) {
.fc-h-event.tentative {
border-style: dashed;
}
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
box-sizing: border-box;
border-style: solid;
border-width: 2px;
}
}
.fc-day-past .fc-daygrid-day-events {
opacity: 0.5;
}
@@ -75,8 +75,8 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
this._aliases = this._params.entry?.aliases || [];
this._icon = this._params.entry?.icon || null;
this._level = this._params.entry?.level ?? null;
this._addedAreas.clear();
this._removedAreas.clear();
this._addedAreas = new Set();
this._removedAreas = new Set();
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
}
@@ -99,8 +99,6 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
this._addedAreas.clear();
this._removedAreas.clear();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -1,4 +1,3 @@
import { startOfYesterday } from "date-fns";
import {
mdiDelete,
mdiDevices,
@@ -12,22 +11,23 @@ import {
mdiShape,
mdiTools,
} from "@mdi/js";
import { startOfYesterday } from "date-fns";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { consume } from "../../../common/decorators/consume";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { createSearchParam } from "../../../common/url/search-params";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { createSearchParam } from "../../../common/url/search-params";
import { groupBy } from "../../../common/util/group-by";
import { afterNextRender } from "../../../common/util/render-status";
import { createColumnsController } from "../../../common/util/responsive-columns";
@@ -37,9 +37,10 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
@@ -67,8 +68,8 @@ import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import "../../logbook/ha-logbook";
import { isHelperDomain } from "../helpers/const";
import {
loadAreaAddToDialog,
showAreaAddToDialog,
@@ -363,7 +363,7 @@ export default class HaAutomationActionRow extends LitElement {
this._entityReg,
this.action,
undefined,
{ hideTriggerIds: true },
undefined,
this._manifests
)
)}
@@ -1619,10 +1619,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
background-color: var(--ha-color-fill-primary-normal-hover);
}
wa-tree-item::part(base).tree-item-selected .item {
background-color: yellow;
}
ha-list-base {
--ha-row-item-padding-inline: var(--ha-space-3);
--ha-row-item-padding-block: var(--ha-space-1);
@@ -205,9 +205,7 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
hideTriggerIds: true,
})
describeCondition(this.condition, this.hass, this._entityReg)
)}
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
@@ -49,7 +49,7 @@ export class HaTriggerCondition extends LitElement {
}
protected render() {
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const triggerIdOptions = this._triggers?.options ?? [];
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
@@ -174,7 +174,7 @@ export class HaTriggerCondition extends LitElement {
};
private _selectId(id: string, checked: boolean) {
const ids = ensureArray(this.condition.id).filter(Boolean);
const ids = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const selectedIds = checked
? ids.includes(id)
? ids
@@ -202,7 +202,9 @@ export class HaTriggerCondition extends LitElement {
private _missingIds = memoizeOne(
(ids: TriggerCondition["id"], triggerIdOptions: TriggerIdOption[]) => {
const availableIds = new Set(triggerIdOptions.map((option) => option.id));
return ensureArray(ids).filter((id) => id && !availableIds.has(id));
return (ensureArray(ids) ?? []).filter(
(id) => id && !availableIds.has(id)
);
}
);
@@ -869,7 +869,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_entityReg")) {
// The filtered list holds entity IDs, so also refresh it when automations
// are added or removed
if (changedProps.has("_entityReg") || changedProps.has("automations")) {
this._applyFilters();
}
}
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more