Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld ac32a42c3f Show the overview as a bottom sheet on phones
The overview had its own collapsible panel with a handle button and swipe
handling. On phones it now renders inside ha-resizable-bottom-sheet in its
persistent mode, so it resizes like the automation editor's sheet and stops
at a strip with the tabs instead of closing. The sheet reports its height
for the map's insets and fit padding; wide layouts keep the floating panel.
2026-09-11 17:09:25 +02:00
Maarten Lakerveld 7dfa52b89e Let the resizable bottom sheet stay open at a minimum height
A persistent sheet stops at a pixel minimum instead of closing when dragged
down, can open at its content height within a configurable share of the
viewport rather than at least 55%, reports its height after opening and
resizing, and lets a host shrink the grab area below the handle for content
that sits right under it. The map overview uses all of this on phones; the
automation editor keeps the previous behavior.
2026-09-11 17:09:25 +02:00
Maarten Lakerveld 0abfe10a0a Show the activity timeline as logbook rows
The overview rendered its own timeline items. It now uses ha-logbook-entry
in its narrow layout, so activity reads like the logbook everywhere: the
person, the zone they are in, and when. Arrivals and departures on the zone
tab are the same rows for the person who moved. The card hands hass to the
overview only for these rows.
2026-09-11 17:09:25 +02:00
Maarten Lakerveld 55ce2f8708 Extract the activity timeline parsing and test it
The window seeding, unavailable skipping and zone transition rules moved
into pure functions with tests, so a regression in the People or Zones
timeline no longer needs manual checking.
2026-09-11 17:09:25 +02:00
Maarten Lakerveld 6e30b2ed1b Keep the scale ruler and attribution beside the drawer on wide layouts
The bottom inset that lifts the map controls above the drawer only applied
on phones, so on desktop a tall drawer covered the scale ruler. ha-map gains
left and right insets for its bottom controls, and the card reserves the
drawer's width on the side it occupies.
2026-09-11 13:36:46 +02:00
Maarten Lakerveld 49091bf51c Let the activity timeline scroll inside the drawer
On phones the drawer body is capped at 60vh and clips its overflow for the
collapse animation, so activity entries beyond that were unreachable. The
activity box now scrolls within the drawer.
2026-09-11 13:35:59 +02:00
Maarten Lakerveld b5afdd20dd Keep automatic fits clear of the overview
Only a selection fit knew about the drawer; the first fit and every
auto-fit used the whole viewport, so markers could land under the drawer.
ha-map takes a fit padding that automatic fits apply, and refits when it
changes, so the map settles once the drawer reports its size.
2026-09-11 13:35:26 +02:00
Maarten Lakerveld 2a1c79fd7f Take the map card and overview colors from the registry context
The card no longer runs its own color subscription, and the overview reads
the registry entries for its zone list.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld f8c0e90962 Report activity that could not load, and list only events inside the window
A failed history request, or an instance without the history integration,
read as no recent activity; the panel now says the activity could not be
loaded. The first history sample is the state at the window's start, not
a change inside it, so a person who has not moved for days no longer shows
that old state as recent activity: samples before the window only seed
the previous state, for zones and people alike.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld 52558a6efa Keep a focused marker clear of the overview
Focusing a person or zone fitted the full viewport, so the marker could
land under the overview: the start side on desktop, the bottom sheet on
phones. Fits now take viewport padding, both engines apply it per fit,
and the card passes the overview's size as padding on the side it covers,
honoring right-to-left layouts.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld 7606a8e1bf Reuse the cards' show-more-info label in the map overview
The overview added its own copy of a label the other cards already have
translated; it uses the shared key and the duplicate is gone.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld cc7a69c4a3 Pause auto-fit before a deferred fit, and never select an empty tab
An explicit fit that had to wait for the engine or for a size did not
pause auto-fit until it ran, so an auto-fit queued in between could take
its place. The pause comes first now.

The overview's selected tab could be an empty, disabled one, showing an
empty list while another tab had content and leaving aria-selected on a
disabled button; the selection falls back to the first tab with items,
which the tab stop already did.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld 8c50c84c48 Resubscribe the map card's colors when the connection changes
The card kept its entity color subscription on whichever connection it
first saw. It now remembers that connection and subscribes anew when
hass brings a different one, as ha-map already does.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld 7031a89b2e Add people, devices, and zones overview to the map card in panel view
When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld 5a927fa059 Address code review: keep the editor alert clear of the zoom control in RTL
The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld a4dd4344e9 Address code review: recolor the map card from the themes context
The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.
2026-09-10 17:18:21 +02:00
Maarten Lakerveld 0bd742e106 Address code review: follow theme changes and keep bubble focus
The map card rebuilds its entity colors and trail colors when the theme
changes, ha-map redraws editable circles on a theme change, and the
locations editor rebuilds its cached marker elements. A focused cluster
bubble keeps focus on the bubble that replaces it, and a caller naming a
marker by aria-labelledby keeps that name.
2026-09-10 16:46:07 +02:00
Maarten Lakerveld e57b337bb4 Address code review: recolor history trails when the registry changes
The trail colors are memoized with the registry entries as a key, like the
marker colors, so trails recolor with their markers when the registry
arrives or changes.
2026-09-10 16:12:17 +02:00
Maarten Lakerveld b55912844f Address code review: one palette sequence for zones, persons and trackers
Per-domain sequences all started at slot 0, so the first person and the
first zone shared a color and a person could match the zone it was in.
2026-09-10 15:49:32 +02:00
Maarten Lakerveld 47a789b64f Address code review: take entity colors from the registry context
The color module kept its own shared registry subscription with listener
counting and connection takeover. The lazy fullEntitiesContext already does
that for the whole app, so the module is now pure: it turns the registry
entries into a palette index, memoized per registry update. Maps ask for
the context only once they draw entities, so an editor's map, as in
onboarding, never requests the registry; the map card and the zone page
consume it directly.
2026-09-10 15:28:18 +02:00
Maarten Lakerveld 2ae8e2b777 Address code review: color map card entities from the shared palette
The card kept its own color dictionary, so an entity could have a different
color on every map. It now uses the palette that ha-map and the zone editor
already share, ordered by registry creation; a configured color still wins.
2026-09-10 15:00:09 +02:00
Maarten Lakerveld 6a135af0da Address code review: keep the label mode in cluster bubbles
Bubble avatars only carried the label text and picture, so an entity set to
the icon label mode showed initials once clustered and the attribute mode lost
its unit. The icon flag and unit now travel with the cluster data.
2026-09-10 14:48:18 +02:00
Maarten Lakerveld c263b14629 Remove label dedupe
The doubled place labels are a tile issue, not part of the marker redesign,
and the population filter did nothing on the OSMF tiles, which fill in a
default population for every place. It moves to its own branch.
2026-09-10 13:48:11 +02:00
Maarten Lakerveld 8de9063a09 Address code review: drop settled zone save queues
The per-zone save chain kept its last resolved promise for the life of the panel.
2026-09-10 11:16:39 +02:00
Maarten Lakerveld adc87d7f43 Address code review on the marker redesign, third round
The zone page's updated() override never called its base, so once
willUpdate stopped misusing super.updated the subscribe mixin had no hook
left to run hassSubscribe when hass arrives after connection; the override
calls super.updated now.

Saves for one zone run in order, each looking the entry up when it starts,
so a second response can replace what the first produced, and a failure
only drops the pending values its own request carried. A passive zone that
is not drawn no longer anchors a cluster bubble. A map whose entity list
empties releases the color subscription, not only one whose list is unset.
2026-09-10 10:34:40 +02:00
Maarten Lakerveld 0ebff36c70 Release the entity color subscription when a map stops drawing entities
A map whose entities were unset kept its registry subscription, holding
the shared stream open for nothing. It is released along with the
markers, and the three release sites share one helper.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 87c70bbabd Count entity color subscriptions from the listener set
The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld dda34bda66 Redraw markers on a theme change, and test the entity color assignment
Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e99b1b38a1 Fall back to the palette color for entities without one, recolor zones on theme change
An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 78b0c8e59a Drop the numeric cluster badge styles, follow a replaced connection for colors
Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 74d87f3861 Tell the user when a dragged zone edit fails to save
A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 3cd2422cce Address code review on the marker redesign
The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 36b95468d6 Subscribe to entity colors only for maps that draw entities
Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld b297f369dd Keep dragged values on the zone page until they are saved
The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e3411c0be5 Drop the editor's drag classes
The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 1fd57d19e3 Frame person and device markers, ring the selected one
Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 655d809603 Redesign map markers with zone circles and avatar cluster bubbles
Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 99f5f0c428 Own the accessibility attributes the engine sets, and use spherical circle geometry
setMarkerAccessibility only filled in what was missing and nothing removed
it, so a cached element rebuilt with a new title or a different role kept
the old attributes. The helper now records what it set, replaces exactly
that on a rebuild, and clears it when the element leaves the map, while a
caller's own attributes stay untouched.

The flat meters-to-degrees conversion broke down for large circles near
the poles, wrapping longitude and producing latitudes beyond 90. Handle
placement and the polygon now use the great-circle destination, and the
fitted bounds a spherical box that spans all longitudes once the circle
reaches a pole, so all three agree with distanceMeters everywhere.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ccd284b91b Make marker activation opt-in, and relabel handles on language change
Every editable marker was a focusable button whose activation fired an
event, whether or not the host did anything with it: the zone page and
the location selector do not. Activation is now opt-in per location; the
onboarding search results opt in, and a marker that does not act on it is
draggable without button semantics. The engine contract separates taking
pointer input from being a keyboard-focusable button for that.

Marker titles and handle labels are localized when drawn, so a language
change, or localization arriving after the first draw, redraws them.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ad6df7ac81 Drop the zone page's active entry
It only re-selected the row the user had just clicked, after the shared
request-selected helper had reset it, which fired the selection event a
second time and zoomed to the zone twice. The row highlight comes from
the list itself and nothing else read the value, so the list no longer
mirrors a selection at all.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 7cec2a2d6f Share one circle geometry, and keep arrow keys on the handle
Drawing, fitting, and handle placement each converted meters to degrees
on their own, with different pole guards, so above 89.4 degrees the
resize handle left the drawn circle. They now share one conversion that
also agrees with distanceMeters; only the pole itself is guarded.

Arrow keys on the resize handle also reached MapLibre's keyboard handler
and panned the map while resizing; the handle keeps them.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 5f1254983a Show the fallback notice over the map, and keep handle clicks off the map
The notice followed a map that fills the editor's fixed height, so it
landed outside the box: clipped in onboarding, spilling into the form in
the location selector. It now sits over the top of the map, clear of the
zoom control.

The resize handle was the one marker whose click reached MapLibre's map
click, which a pin-on-click host takes as a request to move the location
there; the handle stops it like the other markers do.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 017ce0dd1e Announce the fallback notice, and let test frames redraw
The notice appears after the engine has been chosen, so helper text
would not reach a screen reader; it is an alert now. The engine test's
frame stub returned an id that landed in the engine's pending frame after
the callback had cleared it, so every redraw after the first was skipped.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 8e5d5e1311 Drop the named icon's move cursor, and describe the fallback as a map state
The Leaflet fallback draws named location icons statically, but the
stylesheet still gave the ones marked editable a move cursor. The engine
sets that cursor on the elements it actually drags, so the rule goes.

The fallback notice blamed the browser, while MapLibre can also be
unavailable after a failed start or a lost WebGL context; it now
describes what the map offers. The resize handle's east point only guards
the pole itself, so its distance agrees with distanceMeters wherever a
zone can be.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld b72f7529e1 Show the fallback notice only when a location is editable
The Leaflet fallback showed the notice for every editor, including ones
whose locations are all read-only, where nothing is missing.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld d960c67884 Let the engine own the drag cursor, and tidy the fallback notice
The move cursor came from stylesheets keyed on whether a location may be
edited, so it showed on circles that cannot move and on the static
Leaflet fallback. The MapLibre engine now sets it on the elements it
actually made draggable and clears it when handing them back.

The fallback notice said the browser cannot edit locations, while
clicking the map to place one still works there; it now names what is
missing, dragging markers and resizing zones. The editor's
internationalization context is reactive, like its neighbours.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld dd61d9934b Guard a keyboard resize against host updates like a drag
Arrow-key resizing changed the radius without arming the pre-edit guard
that pointer drags use, so a host re-render with the old radius while a
key was held, or its echo right after the commit, snapped the circle
back. A key resize now counts as a drag from the first key press until
the commit.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 21855a80c1 Hand marker elements back clean, and record sources before the style loads
MapLibre leaves its positioning class and transform on a marker element
after removal. Hosts like ha-locations-editor cache their elements and
hand them to whatever engine draws next: after a fatal WebGL failure that
is the Leaflet fallback, which shows the locations statically. A removed
element, and every element still on the map when the engine is destroyed,
is reset so it arrives as a plain element. The editable circle's center
gets the same treatment.

Custom sources and layers are now recorded when added and only inserted
into the map once the style has loaded. A circle moved while a swapped
style was still loading therefore reaches the record, and a swap in that
window carries it over; the deferred insert skips what the swap already
placed.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 5f1a10f844 Leave zone row selection to the user
Clicking or dragging a zone on the map, and saving one in the dialog,
selected its row in code. That selection fires the same request-selected
event a click ends with, so the page had to tell the two apart, and the
row could not be shown selected and stay clickable at the same time: the
highlight never actually appeared. The list now only follows the user's
own clicks, which removes that bookkeeping along with the zoom that a
radius drag used to trigger.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ed87bd316d Test the map editing path
The MapLibre engine's editable circle against the fake map: dragging the
center moves the circle and the handle and reports the drop, dragging the
handle resizes and snaps it back, arrow keys resize in steps committed on
release, the advertised maximum follows a larger radius, one update
echoing pre-drag values is ignored, the center activates by click, Enter
and Space but not after a drag, and removal clears markers, layers and
listeners.

ha-map's reconciliation of editable locations through a fake engine with
editing support: circles and markers are drawn, moved in place, rebuilt
when their appearance changes and removed when dropped, move, resize and
activation events carry the location id, and an engine without editing
support shows the locations statically.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e270ba79a2 Address code review on map editing
Editable markers, the circle center on both engines, and the static
fallback center activate on Enter and Space, as their button role
promises. Locations without a name get a localized one, since onboarding
and the location selector pass none. The resize handle's advertised
maximum follows the radius instead of being exceeded by it, and the
handle stays near the circle at high latitudes.

Clicking the active zone row zoomed to it again upstream but was
swallowed by the check that told a map-driven selection from a click; a
one-shot flag on selections made in code replaces that inference. The
engine rebuilds its sources from its own record on a style swap, so a
radius changed while the style was unloaded is not lost.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld f651afe1fe Keep the retained circle data current while resizing
The engine keeps its source specifications to rebuild them after a style
swap; the editable circle's redraw updates that copy along with the map.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld f4398b688d Edit zones and locations on the map engine instead of leaflet-draw
ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

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

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

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

The layers property and the engine selector are removed along with the
leaflet-draw dependency and its patch: Leaflet is now purely the viewing
fallback, selected by WebGL2 support alone. The Leaflet engine keeps a
leafletMap field for the ha-map fit tests, which run on it in jsdom.
2026-09-10 09:55:41 +02:00
Dominik c3e341bdb3 Unify trend-graph and temperature-forecast card-features (#54037)
Unify trend-graph and temperature-forecast
2026-09-10 09:01:57 +03:00
Paulus SchoutsenandClaude 58de8df46a Prevent closing the backup restore dialog by clicking outside (#54095)
Prevent scrim close on backup restore dialog

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


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

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

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

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

* Use automation descriptions instead of target ui

* Type check

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

* Format condition editor

* Fix contextual condition descriptions

* Resolve state references in condition descriptions

* Share automation condition headers with visibility conditions

---------

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

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

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

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

* Enhance keyboard accessibility for icon click

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

* Fix dark mode style application logic

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

* Address code review on the map engine

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

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

* Address second code review round on the map engine

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

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

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

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

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

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

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

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

* Tear down an engine that fails while still setting up

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

* Make history point popups readable on MapLibre

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

---------

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

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

Fix show_values label alignment on Sankey nodes

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

* Address review feedback on show_values

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

* Apply batched suggestions from code review

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

* Format Sankey card templates with Prettier

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

* Fix vertical Sankey value labels wrapping on thin nodes

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

* Reserve extra bottom space for vertical Sankey values

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

* Keep locale grouping spaces in Sankey value labels

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

---------

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

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

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

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

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

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

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

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

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

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

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

* Modbus: a serial port is open, not connected

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

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-09 07:08:22 +02:00
Aidan Timson 64cda9e0e7 Clarify companion skill selection before frontend review (#54067) 2026-09-08 20:21:56 +02:00
renovate[bot] 94fdea6f00 Update dependency vite to v8.2.2 (#54058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-08 15:26:50 +02:00
Petar PetrovandAidan Timson 37de8c4b15 Delegate dashboard visibility conditions to core (#52904)
* Add dashboard visibility condition classifier, translator and splitter

Pure-logic foundation for delegating dashboard visibility conditions to
core (#52836).

- Add a VisibilityCondition type spanning the client-only lovelace
  conditions (screen, user, view_columns, location, time) and core
  automation conditions, alongside the existing lovelace Condition.
- translate.ts: classify conditions as client- or server-evaluated and
  translate server ones to core format (entity -> entity_id, state_not
  -> not-wrap, numeric bound coercion), preserving lovelace's
  not = not(AND) semantics.
- split.ts: split a tree into maximal server subtrees (one subscription
  each, sibling-grouped) plus a three-valued client combiner.

* Add reactive condition evaluator controller

Phase B of delegating dashboard visibility conditions to core (#52836).

- ConditionEvaluatorController opens one subscribe_condition per server
  subtree, evaluates client leaves locally, observes screen/time
  boundaries, and exposes a tri-state visible/hidden/unknown result plus
  error. It recomputes on push/listener/hass/context change, debounces
  re-subscription when the tree changes, and tears down on disconnect.
- Add observeConditionChanges to listeners.ts (notify-only, decoupled
  from checkConditionsMet) and widen extract.ts to the VisibilityCondition
  tree, factoring time-boundary scheduling into a shared helper.

* Harden condition translation for incomplete and odd numeric inputs

Follow-up to the adversarial review of the translator (#52836):

- Incomplete/garbage state conditions (no entity, no value, or an empty
  object) now translate to an always-false core condition instead of a
  schema-invalid `state`, matching checkConditionsMet and avoiding a
  broken grouped subscription.
- numeric_state bounds: coerce only finite numeric strings (incl. "" -> 0)
  to numbers, pass genuine entity-id references through, and drop junk or
  non-finite strings (matching lovelace's "NaN -> ignored" and never
  emitting a non-JSON-serializable Infinity).

* Fix condition evaluator controller lifecycle edge cases

Follow-up to the adversarial review of the controller (#52836):

- Key re-subscription on a structural signature of the condition tree
  rather than array reference identity, so a host re-deriving the array
  each render neither starves the debounce nor churns subscriptions.
- Reset the published result to `unknown` on host disconnect so a
  detached/reconnecting host never renders a stale, no-longer-live result.
- Read hass lazily in the time-boundary listeners so timezone changes are
  picked up on the next boundary instead of being pinned at subscribe time.

* Evaluate dashboard visibility through the reactive condition evaluator

Rework ConditionalListenerMixin to derive visibility from
ConditionEvaluatorController instead of evaluating checkConditionsMet
synchronously. Stateful conditions (state, numeric_state, template, sun,
zone, device, integration) are delegated to core via subscribe_condition;
client-only conditions (screen, user, view_columns, location, time) stay
local. The mixin re-feeds the evaluator on connect and on hass/config/
column changes, and drives _updateVisibility from its tri-state verdict.

Consumers (hui-card, hui-badge, hui-section, hui-heading-badge,
hui-view-sidebar, hui-conditional-base) now read the mixin's
_conditionsVisible(), which prefers the server-aware verdict and falls back
to an optimistic synchronous seed while a server subtree is pending — exact
for legacy lovelace conditions (no flash for existing dashboards) and hidden
for core-only conditions until the server reports.

- fold the host entity_id context into the evaluator path via
  addEntityToCondition, and read core-format entity_id in
  checkStateCondition / checkStateNumericCondition so seed and server agree
- addEntityToCondition no longer grafts a context entity onto an
  already-core condition that carries its own entity_id
- the conditional card/row now evaluates legacy {entity, state} conditions
- cache the entity-folded array so the evaluator's signature memo holds
  across hass updates, and drop the cached verdict when the tree changes by
  value so the seed is used for the new tree

* Add server condition types to the dashboard visibility editor

Let the visibility editor add and edit the core-format server condition
types (template, sun, zone, device) by embedding the automation condition
editors, which already speak core format. ha-card-condition-editor
dispatches these types to ha-automation-condition-editor; the existing
lovelace editors and the and/or/not containers are unchanged, and because
the logical editors nest ha-card-condition-editor, mixed trees dispatch
each child correctly.

- extend the add-condition menu with the new types (icons + labels)
- suppress the client-side live-test for server-class conditions (and any
  logical tree containing one); checkConditionsMet can't evaluate them, so
  the indicator stays neutral instead of showing a misleading failure

Server-backed live-test and read-both/write-new conversion of lovelace
state/numeric_state conditions are follow-ups.

* Evaluate the visibility condition editor live-test through the reactive evaluator

Drive the per-condition live-test indicator with the same
ConditionEvaluatorController the dashboard uses at runtime, so server-class
conditions (template/sun/zone/device and core-format state/numeric_state) get
a real subscribe_condition-backed verdict instead of a neutral indicator.
Client-only conditions stay evaluated locally and mixed logical trees combine
both via three-valued logic.

- Fold the card entity into the observed condition exactly as the runtime
  mixin does, and memoize the folded array so the evaluator's signature memo
  keeps hitting on hass-only updates.
- Map the evaluator verdict to the indicator: visible -> pass, hidden -> fail,
  pending -> unknown, server error -> invalid (raw error shown as the tooltip
  detail, localized label kept as the aria-label).
- Keep a client-side validity check for purely client trees so a malformed
  client-only config still surfaces as invalid.
- Recurse the no-entity (filter-mode) suppression so nested entity-less
  conditions are handled, and report an as-yet-unknown manual test as no
  result rather than a failure.
- Drop the now-unused invalid-config alert and its orphaned translation keys.

* Evaluate the visibility status banner server-side

Drive the card-level visibility summary banner with the same
ConditionEvaluatorController used by the per-condition live-test, so a set
containing server-class conditions reports its real visible/hidden verdict
instead of being flagged as an invalid configuration.

- Add a distinct "unknown" banner state for while a server result is still
  pending, separate from "invalid" (a genuine configuration error).
- Keep a client-side validity check for purely client trees, and fold the
  card entity into the observed conditions, matching the per-condition editor.
- Extract isPureClientCondition (every leaf client-side, as opposed to
  isClientCondition's any-leaf) so both consumers share one classifier.

* Edit dashboard state conditions via the core condition editors

Route `state` / `numeric_state` visibility conditions to the core automation
condition editors (outside entity-filter mode), so they share one editing
surface with the server-class types and are authored in core format.

- Read both: existing lovelace-format conditions (`entity`, `state_not`, …)
  are translated to core for display; a `state_not` shows as `not(state)`.
- Write new with touch-to-convert: opening a condition leaves it untouched;
  editing or adding one persists it in core format (`entity_id`, `state` list).
- Entity-filter mode keeps the lovelace no-entity syntax and editors.
- Register the core `state` / `numeric_state` editors (dynamicElement only
  renders a tag, it does not define the element) and widen the editor chain's
  condition arrays to the mixed visibility union.

* Remove the superseded client-only condition listeners

The dashboard now evaluates visibility through the reactive condition
evaluator (which uses `observeConditionChanges`), so the old synchronous
client-only listener path has no remaining callers.

- Delete `ConditionListenersController` and the `setupConditionListeners` /
  `setupMediaQueryListeners` / `setupTimeListeners` helpers.
- Keep `observeConditionChanges` and the shared time-boundary scheduler, and
  re-point their tests onto it so the scheduling edge cases stay covered.

* Pin the condition editor live-test for hidden and invalid configs

The per-row live-test set `invalid` (or hidden) directly but left its
evaluator callback unguarded, so the evaluator's torn-down `unknown`
result — fired ~500ms after observing `undefined` — clobbered the pinned
state until the next hass tick re-pinned it, causing a transient flicker.

Add the same `_override` guard the sibling visibility-status banner
already uses: the hidden / client-invalid branches set the result
directly and pin it, and the evaluator callback ignores results while
pinned.

* Remove the orphaned conditional-listener no-op methods

`addConditionalListener` / `clearConditionalListeners` became no-op
shims once the evaluator took over subscriptions and teardown; their only
callers were the listener-wiring removed in the migration. Drop both
methods, the mixin's now-redundant `disconnectedCallback` override, and
the stale `clearConditionalListeners()` call in hui-conditional-base.

* Accept server-evaluated conditions in validateConditionalConfig

The conditional card/row/element gate their config on
validateConditionalConfig, which only knew the client-side condition
types and rejected the server-evaluated ones (template, sun, zone,
device, and integration-provided conditions). Now that those are
authorable and delegated to core, accept them — core validates them —
so configuring one no longer throws "Invalid configuration".

* Evaluate conditional picture element visibility server-side

The picture-elements `conditional` element was the one visibility
consumer still evaluating fully client-side, so its stateful conditions
were not delegated to core (and server-class types it could not evaluate
fell through to a permanently-hidden result). Convert it to a
ReactiveElement driven by ConditionalListenerMixin, exactly like its
sibling hui-conditional-base, so the evaluator delegates stateful
conditions through subscribe_condition and evaluates the client-only
ones locally.

* Keep conditional cards mounted while hidden for server conditions

A conditional card gated on a server-evaluated condition (template, sun,
zone, device) never reappeared: hui-card removes a hidden child card from
the DOM, tearing down the evaluator, and the synchronous seed can revive a
client condition but not a server one, so the subscription was never
(re)opened and the server result that would show the card never arrived.

Set connectedWhileHidden so the conditional card/row stay mounted while
hidden and keep their subscriptions alive, like the other cards that must
keep working while hidden. The inner element is still unmounted when
hidden, so there is no extra render cost.

* Load config translations for the reused condition editors

The dashboard visibility editor reuses the automation condition editors
(state, numeric_state, template, sun, zone, device), which label their
form fields from the `config` translation fragment. The lovelace panel
never loads that fragment, so those labels rendered blank — e.g. the
numeric_state limit-type selectors and the above/below fields.

Load the `config` fragment when the conditions editor first renders, so
the embedded editors resolve their labels.

* Fold the card entity into entity-less conditions on read

A card with a host entity can carry entity-less state / numeric_state
visibility conditions that implicitly target that entity (folded in at
runtime). The editor translated them with an empty entity_id, so the
reused automation editor showed an empty, invalid entity field.

Fold the current-mode context entity into the displayed condition, and
recompute it when the entity context arrives (it can follow the
condition). Opening still does not rewrite the stored config; only
editing converts it to explicit core format.

* Demote the unused state/numeric_state condition editors to base classes

With the automation condition editors handling state / numeric_state in
the dashboard editor, the lovelace `ha-card-condition-state` and
`ha-card-condition-numeric_state` elements are no longer rendered. Their
only remaining role is as the base class for the entity-filter
`-no_entity` variants, so drop the now-dead custom-element registrations
(and the redundant side-effect imports) while keeping the shared logic.

* Format condition editors

* Fix condition editor indentation

* Invalidate the condition evaluator result as soon as the tree changes

When the observed tree changed by value, the controller kept the old
split and subscriptions alive until the debounced re-subscribe fired, so
it kept publishing (and the editor's Test action reported) the previous
tree's verdict, and a host that had already reset to unknown never got a
notification when the new tree settled on the same result.

Tear the old tree down when the signature changes and publish unknown
until the new subscription reports. Trees with nothing to subscribe to
re-evaluate immediately rather than waiting out the debounce.

Track a pending re-subscribe with its own flag: undefined is a valid
signature (observation cleared), so using it as the "nothing pending"
marker kept rescheduling the timer on every hass update and could leave
the previous subscription open indefinitely.

* Translate bound-less numeric_state conditions to a schema-valid core condition

Dropping a junk or non-finite bound could leave a numeric_state condition
with neither above nor below, which core's schema rejects; because sibling
server conditions are grouped into one subscription, that single invalid
leaf failed the whole group and hid the content. Lovelace merely ignored
such bounds and only required the value to be numeric, so express that
residual check as a template condition instead. An entity-less
numeric_state resolves to always-false, matching checkConditionsMet.

* Validate every visibility condition client-side and handle unsupported automation editor configs

validateConditionalConfig already accepts the server-evaluated types, so
gating it behind "every condition is client-only" only served to skip
validation as soon as a server condition was present; a malformed screen
condition next to a template condition was reported as hidden rather than
invalid. Run it for the whole set.

The embedded automation condition editors fire ui-mode-not-available when
an existing core config fails their UI struct. Fall back to YAML with the
struct warnings, as the automation condition row does, instead of
rendering a blank editor while still claiming UI support.

* Type dashboard visibility contracts as VisibilityCondition

Card, badge, section, view sidebar and heading badge visibility, plus the
conditional card, row and element conditions, still declared the
lovelace-only Condition type, so the newly supported template, sun, zone,
device and integration conditions were invalid at the config boundary and
needed casts throughout. Declare them as VisibilityCondition, widen
validateConditionalConfig accordingly and drop the casts that made
redundant. Entity-filter conditions stay lovelace-only.

* Subscribe when hass arrives after the conditions were observed

The subscribed signature was recorded before checking that hass was
available. A host that connected with its config set but received hass
later therefore never subscribed: the later observe saw the same
signature, found no split and stayed unknown for good. Only record the
signature once a subscription is actually opened.

* Seed pending visibility with a three-valued local evaluation

While a server result was pending, the mixin ran the whole tree through
the legacy client evaluator. Core-only leaves default to false there, so
not: [template] was inverted to visible until core replied, and core
state / numeric_state options such as for, match and value_template were
ignored, both contradicting the no-flash guarantee.

Evaluate locally only the leaves the legacy evaluator reproduces exactly
(client-only types, lovelace state / numeric_state, and core ones without
those options), keep every other leaf unknown through and / or / not, and
hide while the outcome still depends on an unknown leaf.

* Hide server-only condition types in entity-filter mode

The map and entity-filter cards still evaluate their conditions locally
with checkConditionsMet, where template / sun / zone / device fall through
to a failing state check and filter out every entity. Don't offer them in
the add menu for that mode.

* Leave conditions carrying enabled to the server in the local seed

Core treats a disabled condition as neutral (and enabled may be a
template), while the legacy evaluator ignores enabled altogether. A
pending not: [disabled failing state, ...] was therefore seeded visible
although core reports hidden. Treat any node carrying enabled as unknown.

* Reject filter-incompatible pasted conditions in entity-filter mode

The condition clipboard is shared across editors in the session, so a
template / sun / zone / device condition, or a core-format leaf pinned to
its own entity_id, could be pasted into a map or entity-filter editor,
where the local evaluator would filter out every entity or evaluate the
wrong one. Hide the paste entry and ignore the action for such clipboard
contents while in filter mode.

* Re-feed the section visibility evaluator after a strategy config resolves

_config is not reactive, and for a strategy section it is assigned after
the Lit update that last fed the evaluator, so _conditionsVisible kept
reading the verdict for the raw or previous config until the next hass
update. Re-run setupConditionalListeners right after assigning it.

* Hide content whenever a server condition subtree errors

An errored subtree was recorded as false and fed through the combinators,
so a client-side not inverted it and an invalid server condition could
show content; the runtime mixin only looks at the result, not the error.
Force the result to hidden while any subtree is in error.

* Honor enabled: false on visibility conditions

Core skips a disabled condition inside a compound: it neither passes nor
fails. Rebuilding logical conditions during translation dropped enabled
(and the other row metadata), so a disabled server-side group was
evaluated, and the mixed-tree split and the local seed ignored it as well.

Preserve the row metadata on translated logical conditions, skip disabled
nodes when splitting and when seeding, and leave a template-valued
enabled on a seeded node unknown since only core can render it.

* Type the conditional card conditions as VisibilityCondition

ConditionalCardConfig, the contract HuiConditionalBase actually consumes,
still declared the lovelace-only union, unlike the row and element
contracts.

* Keep a client-side node with a template-valued enabled unknown

Only core can render enabled: "{{ ... }}", and core never sees a mixed or
client-only node, so the split silently treated such a node as enabled
and let its children decide visibility. Keep it unknown instead, erring
toward hiding and matching the optimistic seed.

* Whitelist the condition types usable as entity filters

The filter-compatibility check only excluded the four built-in server
types, so an integration-provided condition copied from a visibility
editor could still be pasted into an entity-filter editor, where the
local evaluator cannot evaluate it and filters out every entity. Check
against the types checkConditionsMet supports instead.

* Fold the host entity into legacy entity-less conditions as well

A legacy condition such as { state: "on" } has no condition key, so
addEntityToCondition never stamped the card entity on it. That used to be
harmless because checkConditionsMet fell back to the evaluation context,
but the server translation has no context and turned the condition into
always-false once core reported. Treat the legacy shape as the state
condition it is, and make the helper generic over the visibility union so
callers no longer need casts.

* Keep the lovelace outcome for infinite numeric bounds

Number("1e400") is Infinity, which lovelace does not ignore: above +inf
or below -inf can never pass, while above -inf or below +inf always do.
Dropping such a bound could flip an untouched condition from hidden to
visible. Resolve the unsatisfiable cases to always-false and drop only the
vacuous ones. Infinity is not JSON-serializable, so neither is ever sent.

* Only leave a template-valued enabled unknown in the local seed

The check also matched enabled: true, so an explicitly enabled condition
was seeded unknown and the card hidden until core responded, exactly the
flash the seed is meant to avoid.

* Show no test chip while the condition evaluator reports an error

An invalid server condition evaluates to hidden plus an error; reading
only the result turned Test into a transient "not met" chip that
contradicted the invalid live indicator.

* Seed only core conditions the client compares identically

The optimistic seed still admitted core-format shapes the legacy evaluator
handles differently: an attribute comparison (core compares the raw value,
the client stringifies it) and an entity-valued numeric bound (core errors
when the entity is missing, the client ignores the bound). Leave those
unknown until core reports.

* Accept a single condition as the children of a logical condition

Core's LogicalCondition allows conditions to be one condition or a list,
but every traversal here assumed a list and crashed on the single shape.
Widen the type accordingly and route all traversals through one helper.

* Keep lovelace leaves with legacy-only semantics client-side

Core compares raw attribute values, only dereferences input_* comparison
values and errors on a missing bound entity, where lovelace stringifies
the attribute, resolves any existing entity and ignores the bound.
Delegating such existing conditions changed their visibility silently.

Classify a lovelace-format state / numeric_state leaf that relies on any
of those as client-side so it keeps its exact legacy evaluation; once the
user edits it, it is saved in core format and evaluated by core.

* Drop the pinned editor indicators when leaving an override branch

The empty and client-invalid branches pin the indicator and clear the
evaluator, which is then already unknown; when the condition becomes
valid the pending observation is unknown too, so no notification arrives
and the stale pinned state stayed visible until core first responded.

* Reject core's single-child logical shorthand as an entity filter

Filter consumers and the filter-mode editor expect the lovelace list
shape for logical children, so a pasted core condition using the
single-child shorthand caused a runtime error there. Require a list in
the filter-compatibility check and traverse defensively when looking for
no-entity conditions.

* Seed only core leaves whose entity is present and, for numeric_state, bounded

Core errors on a missing entity and rejects a bound-less numeric_state,
while the client evaluates a missing entity as unknown and passes any
numeric value, so both shapes could flash visible before the subscription
error arrived. Leave them unknown until core reports.

* Re-subscribe when the hass connection object is replaced

Subscriptions are bound to the connection they were opened on. An
unchanged tree observed with a hass carrying a different connection kept
the stale subscriptions; re-open them in that case while still not
churning on ordinary hass updates.

* Keep non-finite bounds distinguishable in the condition signature

JSON.stringify serializes Infinity, -Infinity and NaN alike as null, so
flipping a YAML .inf bound produced the same signature and the old
subscription stayed active.

* Seed lovelace leaves only while their target entity exists

Core reports an error for a condition on a missing entity, which hides the
tree, while the legacy evaluator compares against the literal unknown.
Seeding such a leaf optimistically could therefore flash the content
before the error arrived; leave it unknown instead.

* Do not seed a core state condition that compares against another entity

checkConditionsMet dereferences any existing entity used as a comparison
value, while core only dereferences input_* entities, so such a core leaf
could flash visible before the subscription reported false. Apply the
same entity-reference guard the legacy classification uses.

* Preserve row metadata when rebuilding lovelace leaves

Both the evaluation translation and the editor's display conversion
rebuilt lovelace state / numeric_state leaves without enabled, alias and
note, so editing a disabled legacy condition silently re-enabled it and a
template-valued enabled was dropped before reaching core.

* Surface template rendering errors from condition subscriptions

subscribe_condition events carry template_errors next to a result when a
template inside the condition failed to render. Forward them through the
evaluator's error so the editor flags the condition, without overriding
core's result the way a hard error does.

* Treat a null numeric bound as absent

Lovelace ignores a null bound (== null), but the translator only checked
for undefined and Number(null) turned it into an unintended 0. The editor
conversion now skips null bounds as well.

* Keep the and wrapper for a single-child not and row metadata on fallbacks

Core skips a disabled child, so a bare not over one child evaluates to
true where lovelace's not (¬ of the AND of its children) gives false; the
and wrapper restores that for any arity. The always-false and numeric
value fallbacks also dropped enabled, alias and note, so a template-disabled
leaf would have participated in its parent regardless.

* Treat an empty legacy entity as absent everywhere

checkStateCondition falls back to the host entity with entity || context,
so an existing condition with entity: "" targeted the card entity. The
entity fold, the optimistic seed, the editor conversion and the
translation used nullish checks instead, which kept the empty string and
turned such a condition into an error or an always-false leaf. Use the
same truthy fallback throughout.

* Append non-finite values to the condition signature instead of substituting

A marker string standing in for Infinity could in principle collide with
an equal user string. Keep plain JSON and append the ordered non-finite
values, which is unambiguous.

* Remove a stray brace rendered under every condition editor

A leftover interpolation terminator outside the template expression
rendered a literal "}" below the condition content. Also reject pasting a
condition pinned to an explicit entity into an entity filter, where the
fold-in would keep it and judge every candidate by the copied entity.

* Tidy the optimistic seed's leaf check

Condense its doc comment, take the states map rather than the whole hass
object, and simplify the bound checks.

* Tighten comments on dashboard condition evaluation.

---------

Co-authored-by: Aidan Timson <[email protected]>
2026-09-08 12:53:50 +00:00
841729af93 Upgrade vitest to v5 (#54011)
* Update vitest monorepo to v5

* Add required Vite peer dependency for Vitest 5

* Migrate benchmarks to the Vitest 5 context API

* Stub read-only jsdom globals in Vitest tests

* Revise benchmark reporting instructions in README

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

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-08 14:29:24 +03:00
Clément NotinandCopilot Autofix powered by AI e3f92a6df3 Add 'timedelta_string' to documented Jinja completions (#54056)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-08 11:13:23 +02:00
Wendelin a781228706 Fix add TCA mobile list width (#54054) 2026-09-08 09:43:44 +01:00
renovate[bot] f9e02b628f Update dependency lint-staged to v17.5.0 (#54055) 2026-09-08 09:42:01 +01:00
Clément Notin af8a80f0fe Add timedelta_string template completion (#54050) 2026-09-08 10:32:44 +02:00
chaim-shwartz c01a140751 Fix crash when hass.auth is undefined in partial-panel-resolver (#53962) 2026-09-07 18:33:04 +00:00
e50eaaa865 Add more automation row details (#54018)
* Add for and offset to trigger and condition rows

* Add actions with timeout.

* fix separator

* Fix templated automation row durations

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

* review

* Enhance duration validation with finite check

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

* Add behavior inrow

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 20:31:23 +02:00
renovate[bot] cd8b975a38 Update dependency @codemirror/state to v6.7.4 (#54045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-07 18:26:07 +00:00
Aidan TimsonandCopilot Autofix powered by AI 5dd3a4b662 Add UI/UX readiness skill and strengthen review guidance (#54032)
* Create UX readyness skill

* Refine UI/UX review skill responsibilities

* Account for existing PR review feedback

* Clarify UI/UX readiness evidence

* Simplify UI/UX readiness routes

* Update review guidelines for pull requests

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

* Handle optional gallery artifacts

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 20:21:40 +02:00
renovate[bot] e5fa96465c Update dependency eslint to v10.10.0 (#54044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-07 20:17:55 +02:00
renovate[bot] 79467b51b1 Update dependency browserslist to v4.28.9 (#54043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-07 20:17:49 +02:00
Maarten Lakerveld c3cc2217bf Don't zoom the zone map when opening the home zone editor (#54033) 2026-09-07 14:34:17 +01:00
Marcin BauerandClaude Opus 5 6e6e4d4c4d Point at the sources pane from history and activity empty states (#54019)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-07 14:09:13 +02:00
Aidan Timson f6cbe49d81 Resolve Supervisor browser URLs against Home Assistant (#53944)
* Resolve browser URLs against Home Assistant

* Resolve Supervisor log download URLs against Home Assistant
2026-09-07 13:12:49 +02:00
Aidan Timson 1e9592a0b8 Resolve Supervisor URLs against HA context for icons (#53942)
Resolve Supervisor URLs against Home Assistant
2026-09-07 12:07:28 +01:00
Aidan Timson b2b0491c99 Centralize Supervisor app icons (#53941)
* Centralize Supervisor app icons

* Constrain app icons in combo boxes

* Preserve quick bar app icon styles

* Style colored app icons in combo box items

* Use fixed dimensions for colored app icons
2026-09-07 12:01:55 +01:00
Petar PetrovandCursor Agent 9bffae3ee4 Exit scene live mode when leaving the editor (#53837)
* Exit scene live mode when leaving the editor

Leaving the page previously only unsubscribed, so live-activated
device states stayed behind. Restore stored states unless the tab
is hidden for panel suspend.

* Drop extra unit tests for scene live-mode disconnect.

* Restore scene live states only on editor disconnect.

Back and delete already unmount the editor, which now exits live
mode, so a second applyScene was redundant.

* Resubscribe to scene live edits after hidden-tab suspend.

Existing scenes stayed in live mode when the panel was reattached,
but the state_changed subscription was not restored.

* Keep the scene live subscription across hidden-tab suspend.

Unsubscribing and resubscribing on reattach races the closed
websocket; leaving the subscription in place lets the library
restore it after reconnect.

* Tear down scene live mode on hidden-tab route changes.

document.hidden is not enough: saving a new scene remounts a
new editor. Skip teardown only when an ancestor was detached
(panel suspend). Reuse an existing live subscription on
reattach so new scenes do not get duplicate listeners.

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

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-07 12:08:46 +02:00
renovate[bot] a287aacd98 Update dependency @codemirror/state to v6.7.3 (#54020) 2026-09-07 11:05:14 +01:00
renovate[bot] b29b0bd083 Update dependency @codemirror/view to v6.43.11 (#54010) 2026-09-07 09:24:46 +01:00
ae8aa3813b Add Radio frequency to the demo (#53976)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-09-07 08:27:43 +01:00
Petar Petrov 770caf62f7 Prompt for unsaved changes on history back navigation (#53919) 2026-09-07 09:17:19 +02:00
iven 8fed3ac0c8 Use a readable text color for calendar events (#54009) 2026-09-07 06:54:52 +00:00
98f5bdd703 Add Tags to the demo (#53977)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-09-07 08:39:04 +02:00
karwosts 8e13ba872d Fix 'select all with issues' (#54007) 2026-09-07 08:35:46 +02:00
Dominik 37433c787f Adjust clock and notification drawer backdrop (#54012) 2026-09-07 06:22:14 +00:00
Paulus SchoutsenandClaude Opus 5 dc7ee8dc5a Serial: send consumers to their own config panel (#54000)
* Serial: send consumers to their own config panel

A port used by Zigbee or Z-Wave led to that integration's entry on the
integration page, which is a device list — not where the radio on that port is
managed. Point those consumers at the integration's own panel instead.

Which panel that is was already decided in `ha-config-entry-row`, from the
panels an integration registers at runtime falling back to
`integrationsWithPanel`. That resolution moves to `getConfigPanelPath` so both
callers stay in step, and so a port picks up any panel the integration page
would already link to.

A consumer that is not running keeps its integration-page link: its panel is
not loaded to receive it.

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

* Serial: send the Thread radio to the Thread panel

A Thread radio is held by the Open Thread Border Router app rather than by a
config entry, so it never reached the panel resolution. Map the app to the
integration behind it and look the panel up from there.

The panel is only offered when its integration is loaded, which an app being
started does not imply.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-06 08:27:25 +02:00
karwosts ddc99501af Handle sequence: null in script. (#53999) 2026-09-06 08:25:35 +02:00
karwosts 5ec87269dc Show errors in blueprint editor (#53996) 2026-09-06 08:24:35 +02:00
renovate[bot] 53cf986491 Update dependency @lokalise/node-api to v16.4.1 (#53997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-06 08:24:19 +02:00
renovate[bot] 0388cb8cd0 Update dependency hls.js to v1.7.2 (#53998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-06 08:24:16 +02:00
dependabot[bot] cfefa39b84 Bump the codeql-action group across 1 directory with 2 updates (#54002)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 08:23:44 +02:00
Paulus SchoutsenandClaude Opus 5 5a907614af Add Thread to the demo (#53970)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Thread to the demo

Load the thread and otbr integrations and mock router discovery, dataset
listing and border router info, so the Thread panel renders in the demo.
The preferred network holds Home Assistant's own border router alongside a
HomePod mini and a Nest Hub; an Amazon Echo forms the one other network.

Widens ThreadRouter's brand union with "amazon", which the backend
already returns. The union is still not exhaustive.

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

* Mock the Thread actions and match the dataset casing

The extended PAN ID was lowercase while the TLV string carries it uppercase.
The dataset dialog looks for one inside the other with a case-sensitive
`includes`, so the preferred network's info showed neither the border
router's URL nor its active dataset.

The panel also offers adding a dataset, choosing the preferred network and
border router, resetting the border router and changing its channel, none of
which were mocked, so each rejected as unimplemented. They now change the
mocked data, and `otbr/set_channel` answers with the delay the panel reads
back.

`ThreadRouter["brand"]` is no longer a union. It only reaches `brandsUrl`,
and the backend resolves it from the border agent's vendor name, so every
new vendor meant another frontend change for no typing benefit.

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

* Push Thread network changes to the discovery stream

The panel groups the routers it draws from `thread/discover_routers`, not
from `otbr/info`, so changing the network only in the info left the border
router drawn under its old network for good. The mock now keeps its
subscribers and announces the router again when it moves.

A reset also has to put the border router on a network of its own instead of
reusing the existing extended PAN ID, which the panel groups by, and it adds
that network rather than taking over the preferred one, so the way back onto
the original network stays available.

`ThreadRouter["brand"]` is null for a vendor the backend does not know, and
the panel now leaves out the icon in that case rather than requesting one
that cannot exist.

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

* Build the Thread datasets from real TLVs

The pasted dataset TLV was malformed: an odd number of hex digits, a network
name field declaring ten bytes for nine, and a network key that ran into the
next type, so a decoder derailed partway through. Datasets are now assembled
from type/length/value triplets, which keeps the declared lengths honest and
each dataset's own extended PAN ID and network name inside its TLV.

Each dataset carries its own, so `thread/get_dataset_tlv` answers for the
dataset it was asked about and rejects an unknown one, and moving the border
router carries the active dataset with it rather than leaving the previous
network's behind, which would have hidden the URL again on the new network.

An imported dataset takes its extended PAN ID from the type 0x02 field
instead of the first sixteen characters, which were the timestamp, and a
reset generates eight hexadecimal bytes rather than a value starting
"RESET".

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

* Import Thread datasets from the credentials given

The import invented the channel, network name and PAN ID and stored a
rebuilt TLV, so importing real credentials showed a different network than
the one submitted and read back different credentials. It now takes all four
fields from the TLV and keeps that TLV verbatim.

Anything that does not decode is refused rather than falling back to a
generated ID, so the panel shows its error path for malformed credentials.

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

* Treat a Thread import as a revision of the network it names

Datasets sharing an extended PAN ID are revisions of one network, so
importing credentials for a network that is already known updates it rather
than adding a second card for it, and only a newer active timestamp wins.
Re-importing the credentials the demo itself shows used to duplicate the
network.

The active timestamp is now required, as the backend requires it, and a
reset leaves the default router unset, since nominating one is its own
action in the panel.

A router without a brand no longer reserves the avatar column, so its name
is not left indented against an empty graphic.

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

* Keep the Thread dataset, credentials and channel in step

Deleting a dataset left its credentials behind, so reading them back returned
a network the panel had already dropped. They go with it now.

Changing the channel only moved the border router's reported channel, leaving
the dataset and its active TLV on the old one, so the channel prompt and the
dataset dialog disagreed on the next read. A reset had the mirror problem,
installing a channel 15 network without moving the reported channel. Both
carry the dataset, its TLV and the channel together.

Network names are decoded as UTF-8 rather than one character per byte, so a
non-ASCII name reads correctly and a sequence the backend would refuse is
refused here.

Adds a demo test for the panel: the two networks it starts with, the reset
and rejoin that only show up if the discovery subscription is told, and
credentials being per dataset and going away with one. Checked that it bites
by dropping the announcement, which fails it on the card count after a reset.

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

* Refuse repeated tags and a zero channel in a Thread dataset

A repeated type is malformed and the backend's parser refuses it, rather
than letting the later one win, and a channel field carrying zero is not a
channel it accepts. An absent channel is still fine.

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

* Change the Thread channel without rewriting the credentials

Rebuilding the dataset TLV from its summary replaced the network key, PSKc,
mesh-local prefix, security policy and anything else this mock does not
model with fixture constants, so changing the channel on an imported network
silently swapped its credentials for someone else's. Only the channel
triplet is rewritten now, leaving the rest of the TLV as it was.

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

* Drop the remaining comments from the Thread mock

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-06 08:23:07 +02:00
Paulus SchoutsenandClaude Opus 5 c325392e79 Add Zigbee to the demo (#53971)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Zigbee to the demo

Load the zha integration and mock its devices, groups, configuration and
network settings, so the Zigbee panel, its options, groups, network info
and network map render in the demo. Neighbor tables give the map a mesh of
routers and end devices.

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

* Give the Zigbee temperature sensor a unique entity ID

sensor.bedroom_temperature already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its Zigbee device and undercounting the panel's
entities on that demo.

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

* Label the Zigbee options and take the offline plug unavailable

The options page localizes every field through the backend translations and
falls back to the raw identifier, so the pages showed names like
alarm_master_code. Seed a key per schema field, for the global options as
well as the alarm ones.

The panel reports the TV plug as offline, so its entity has to agree.

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

* Mock the Zigbee panel's write actions

Saving either options page, downloading a backup, changing the channel and
adding or removing a group or its members all rejected as unimplemented, so
every one of those visible controls failed. They now change the mocked state
that the panel refetches.

Saving merges into the stored configuration rather than replacing it, the
way the backend does, so saving one section leaves the rest alone.

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

* Seed the ZHA unavailability timeouts and copy the configuration

The options page falls back to two hours when these keys are absent, so the
battery timeout showed 2 hours where ZHA's default is 6. Both are seeded
now.

The configuration response handed out the backing object, and both editors
mutate the fetched data as controls change, so an edit the user never saved
survived leaving the page. It is copied on the way out; only the update
command writes to the stored configuration.

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

* List the groupable Zigbee devices

The create group page and the add members dialog build their pickers from
this command, so returning nothing left both permanently empty and made the
group commands unreachable from the panel. The lights and plugs are listed
now.

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

* Keep the Zigbee pairing page, backups and group rows honest

The add device page subscribes to the permit command as soon as it opens, so
with nothing registered it sat on its spinner for the full permit duration
behind a rejected subscription. A subscription that simply stays open is
enough, since nothing pairs in the demo.

A backup shared its network and node objects with the live settings, so
changing the channel afterwards rewrote the stored backup, which is the one
thing a backup must not do. They are copied now.

Group endpoints carried no entities, so both pickers labelled every light
and plug "No entities". Each now carries the entity the fixtures give it.

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

* Wire up the ZHA device pages in the demo

The ZHA info card and device actions look up the device's `zigbee`
connection and render nothing without it, so the demo devices only ever
showed the generic device page. Add the connection, and mock the two
commands that become reachable through it: the manage page's cluster
list (with each cluster's attributes and commands, so the attribute and
command tabs are usable), and the reconfiguration subscription, which
now walks the device's clusters and terminates instead of rejecting.

Written attributes are remembered, so reading one back agrees with what
the write button reported.

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

* Carry the registry area on the ZHA device payloads

The group pickers read the area straight off the device payload rather
than resolving the device registry, so the plugs listed no area. The
backend fills it in from the registry; derive it from the registry
fixtures here for the same reason, so the two cannot drift apart.

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

* Make the Zigbee bindings tab work

Both bind buttons ended in their error state: `zha/groups/bind` and
`zha/groups/unbind` were never registered. Registering them is not
enough on its own — group binding lists only a device's client side, so
with every mocked cluster on the server side the table read "No data"
and the button could never be enabled. The remotes now carry the `out`
clusters they would have on real hardware, and `zha/devices/bindable`
returns the routers, so the device half of the tab is no longer hidden.

Also drops the `any` from the configuration update payload, which
already has an exact type.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-05 09:54:59 +02:00
Paulus SchoutsenandClaude Opus 5 241b8c5771 Add Z-Wave to the demo (#53972)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Z-Wave to the demo

Load the zwave_js integration and mock its network status, provisioning
entries and node and controller statistics, so the Z-Wave panel, its
options, statistics, network info, provisioned devices and network map
render in the demo. Last working routes give the map a mesh with
repeaters, asleep and dead nodes.

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

* Give the Z-Wave motion sensor a unique entity ID

binary_sensor.kitchen_motion already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its Z-Wave device and undercounting the panel's entities
on that demo.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 16:20:53 +00:00
06e55141f5 Add MQTT to the demo (#53973)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add MQTT to the demo

Load the mqtt integration and mock topic subscriptions and device debug
info, so the MQTT panel renders in the demo and its listen card streams
messages on the topic you subscribe to.

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

* Give the MQTT sensor entities unique entity IDs

sensor.kitchen_temperature already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its MQTT device and undercounting the panel's entities
on that demo.

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

* Key the MQTT debug info by device and send real payloads

The debug info ignored the requested device, so the garage door's MQTT info
showed the fridge sensor's entity. Return the entities of the device that
was asked for, and nothing for one that is not MQTT.

The discovery payload is the config object, not a JSON string:
`mqtt-discovery-payload` dumps it as YAML, so a string rendered as one
quoted scalar. The frontend type said string while the component already
required an object; it now matches what the backend sends.

A subscription takes a topic filter, so echoing it back as the message topic
invented topics no broker could publish. Wildcard levels now resolve to a
concrete matching topic.

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

* Allow an empty MQTT discovery payload

An entity with debug traffic but nothing discovered has no config, and the
backend sends an empty string, so narrowing the field to an object alone was
wrong in the other direction.

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-09-04 15:55:21 +00:00
Paulus SchoutsenandClaude Opus 5 f6751a8c44 Add Infrared to the demo (#53975)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Infrared to the demo

Load the infrared integration and add the emitter and receiver proxy
entities it reads, so the Infrared panel and its device list render in the
demo. The panel is entity driven, so it needs no WebSocket mock.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 17:48:40 +02:00
renovate[bot] 1fb138337a Update dependency @rspack/core to v2.2.2 (#53982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-04 17:36:36 +02:00
Paulus SchoutsenandClaude Opus 5 50df620a01 Add Matter to the demo (#53968)
* Add Matter to the demo

Load the matter integration and mock its network topology, so the Matter
panel and its network map render in the demo, with a Thread and Wi-Fi
topology that includes a border router and an offline node.

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

* Key Matter node diagnostics by device

The diagnostics and ping mocks ignored the requested device, so every
Matter device reported node 1 on Thread and available. The device page
gates its actions on that: the offline garden sensor offered actions that
need a live node, and the Wi-Fi plug offered a Thread network link.

Both are now derived from the topology the map already renders, so a
device's node ID, transport, node type and availability match it, and an
unknown device is rejected as the backend would.

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

* Give Matter fixtures unique entity IDs and mock its device actions

lock.front_door and sensor.garden_temperature already exist in the home
demo config, whose entities are added last and overwrote the display
registry entries, detaching them from their Matter devices. The panel
counted 3 entities instead of 5 on that demo. Renamed to a side door lock
and a patio sensor, which no demo config uses.

The device page offers commissioning, fabric and credential actions for an
available node, none of which were mocked, so each failed with
command_not_mocked.

Adds a demo E2E test for the panel and its map. Emptying the topology
subscription fails it.

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

* Mock the Matter lock services and fix the commissioning codes

The lock device exposes "Manage lock", whose dialog reads back the response
of matter.get_lock_info and matter.get_lock_users; neither was mocked, so
it always showed its load-failed alert.

setup_pin_code carried the manual pairing code. The three commissioning
codes now agree on the Matter test payload for passcode 20202021.

Pinging an unknown device rejects like the diagnostics command, matching
the backend, which resolves the device before acting either way.

The E2E test now reads the diagnostics of a Thread, a Wi-Fi and an offline
device, so a regression back to one shared response fails it.

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

* Return a credential result and gate the Matter Thread action

`setMatterLockCredential` reads `user_index` off the per-entity response, so
the demo's empty object made saving a code throw. Return the indices the
dialog reads back, and drop the mock for a `clear_lock_credential` service
the frontend never calls.

The device action linking to the Thread panel did not check that the
integration is loaded, unlike the same link on the Matter dashboard, so it
could navigate to a panel that is not there.

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

* Keep the mocked Matter lock users across changes

The manage dialog reloads the list after every add, edit and delete, so the
static response made each change look like it was reverted. Keep the users
per lock entity and mutate them, and hand back a copy so the dialog's
reactive state sees a new list.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 17:34:09 +02:00
renovate[bot] e581172474 Update dependency globals to v17.12.0 (#53981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-04 13:25:55 +02:00
Paulus SchoutsenandClaude Opus 5 59f16eeca2 Add Serial to the demo (#53974)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Serial to the demo

Load the usb integration and mock the serial port listing, so the Serial
panel renders in the demo with connected, available and disconnected
ports.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 13:19:36 +02:00
Paulus SchoutsenandClaude Opus 5 1cda8ee42d Add Bluetooth to the demo (#53967)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-09-04 12:30:33 +02:00
karwostsandCopilot Autofix powered by AI 87a4a70352 Fix websocket hang in picture card (#53930)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:23:16 +02:00
renovate[bot] 046cf4de14 Update dependency typescript-eslint to v8.69.0 (#53961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 19:57:06 +02:00
renovate[bot] d0d5b93106 Update CodeMirror (#53960)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 19:56:25 +02:00
Paul Bottein 3f2c0dae1b Only select ha-input-copy text on click when readonly (#53959) 2026-09-03 19:43:22 +02:00
Aidan Timson 5dea28504e Improve config panels type safety (#53955)
* Improve config type safety

* Type config search event targets

* Type config event handler targets

* Restore
2026-09-03 15:46:28 +02:00
Jan BouwhuisandPaul Bottein 8e1c4e93cf Add reusable state class selector (#53880)
* Add state class selector

* Update src/components/ha-state-class-picker.ts

Co-authored-by: Paul Bottein <[email protected]>

* Fix linter issue

* Rename `state_classes_filter` to `state_classes` as suggested

* Update and rename stateClasses property

* Fis missed rename

* Add default values

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-09-03 15:42:38 +02:00
Bram KragtenandCopilot Autofix powered by AI 5513abbe8f Check if failed import is from us, or from user, and check if chunck 404's (#53940)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 15:15:46 +02:00
Stefan AgnerandCopilot Autofix powered by AI a9862a8f7c Clarify what uninstalling an app deletes (#53923)
* Clarify what uninstalling an app deletes

The uninstall dialog offered a single switch labeled "Also remove app
data". That named the wrong thing twice over: the switch sends
remove_config, which Supervisor applies to the app's configuration
folder, while the app's data folder is deleted on uninstall either way,
independent of the switch.

Relabel the switch after the folder it actually removes, and state in the
dialog that uninstalling permanently deletes the app's stored data, so
the unconditional part of the operation is no longer implied to be
optional.

Rename the local variable and the uninstallHassioAddon parameter to
removeConfig to match the API field they carry.

* Move the app name out of the uninstall dialog title

Dialog guidelines ask to keep user generated content out of titles, as
names can get long enough to be unreadable there. The name now carries
the sentence in the body, which already describes what is deleted.

* Scope the uninstall warning to the app's private data folder

Uninstalling deletes the app's data folder and, when the app maps it,
the public config folder. Files the app wrote through the share, media
or Home Assistant config mappings stay where they are, so promising
that all of its data goes away is wrong for those apps.

Name the folder the deletion is limited to, and say the configuration
folder is only deleted if the app uses one, matching how the Supervisor
API documents the field. Also switch the label to "delete" for
consistency with the sentence above it.

* Name what an app actually keeps in its data folder

Checked the 30 most installed apps for what they write there. It holds
databases (MariaDB, InfluxDB, Grafana, UniFi, Nginx Proxy Manager,
AdGuard), credentials and network identity (Matter fabrics, the Thread
network key, Tailscale node state, Z-Wave JS settings, Mosquitto
accounts) and internal state such as caches and SSH host keys.

"Any other files it created" reaches past all of that. ESPHome keeps
device configuration in the Home Assistant config folder, Node-RED sets
its user directory to /config, WireGuard writes its keys to /ssl, and
those survive an uninstall.

* Fix punctuation in uninstall dialog text

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 15:02:09 +02:00
Wendelin d23314f4ff History: Fix "add targets" on empty state (#53953)
Fix history empty targets button only on closed filterbar
2026-09-03 11:56:12 +02:00
dependabot[bot] fcc5b381d3 Bump fast-uri from 3.1.5 to 3.1.6 (#53946)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.6)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 06:23:38 +02:00
renovate[bot] 564205a3f3 Update tsparticles to v4.4.0 (#53951)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 06:23:01 +02:00
renovate[bot] 86fb936d14 Update dependency @octokit/auth-oauth-device to v8.0.5 (#53950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 06:22:44 +02:00
Jump-333andCopilot Autofix powered by AI 497b7fce49 Add offline devices count to Matter (#53949)
* Added offline devices count to Matter

* Change condition to check for unavailable nodes

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

* Improve offline devices error handling

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 06:22:26 +02:00
Paul Bottein 251aac7e2e Hide empty labels row in more-info related view (#53939) 2026-09-02 19:16:01 +02:00
renovate[bot] 8d2106ccc8 Update dependency @formatjs/intl-datetimeformat to v7.6.1 (#53945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-02 18:24:09 +02:00
Paul Bottein 3de2637da7 Rename AI settings URL from /config/ai-tasks to /config/ai (#53933) 2026-09-02 18:05:59 +02:00
Jump-333 36d048c8ab Add select-slider card feature for select/input_select tile control (#53876)
* Add select-slider card feature for select/input_select tile control

* Modified select-slider card feature to become just a style of the preexistent select-options

* Modified name icons to segmented and removed suggestion

* Fixed issues with punctuation and spaces

* Renamed segmented to buttons
2026-09-02 12:52:54 +00:00
Paul Bottein 4d7d5231d6 Fix seek bar position jumps while dragging and seeking (#53924)
* Keep seek bar position while dragging

* Unify seek bar progress handling in a shared controller
2026-09-02 14:33:43 +03:00
Aidan Timson b5709a56b0 Wait for connectivity, serial, storage settings readiness (#53935)
* Wait for connectivity settings readiness

* Wait for serial settings readiness

* Wait for storage settings readiness

* Simplify delayed serial scenario

* Test delayed connectivity readiness
2026-09-02 14:25:27 +03:00
Bram Kragten 1b834303d0 Fix stale trigger/condition lists (#53932) 2026-09-02 10:44:43 +02:00
Petar Petrov b455a34998 Only pan sankey charts on touch devices once zoomed (#53931) 2026-09-02 10:36:09 +02:00
Paul Bottein d84ef6b1bb Fit the picker popover to its list and align style with the select menu (#53801)
* Fit the picker popover to its list and align it with the select menu

* Use styleMap for the popover custom properties

* Fix combo box list not rendering for direct embedders
2026-09-02 10:15:53 +02:00
Paulus SchoutsenandClaude Opus 5 10ae5c1dd6 Rename AI tasks settings to AI and add Model Context Protocol card (#53906)
* Rename AI tasks settings to AI and add Model Context Protocol card

The AI settings page now shows a second card for the Model Context
Protocol server. When no mcp_server config entry exists, a centered
button starts the config flow. When enabled, the card shows the server
URL, an alphabetical list of registered LLM APIs with copy buttons for
their URLs, a configure button that opens the options flow, and a
disable action that deletes the config entry.

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

* Mock MCP server config entry and LLM APIs in demo

Lets the demo show the enabled state of the Model Context Protocol
card on the AI settings page.

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

* Rename MCP server URL row and mock MCP in the demo

The row heading did not say what makes this endpoint different from the
per API endpoints below it. Call it "Your MCP URL" and show the config
entry title, so it is clear which APIs it serves.

Mock an MCP server config entry and the registered LLM APIs in the demo.

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

* Simplify MCP card URLs to labeled copy buttons

Build the URLs from the origin Home Assistant is browsed on, so the
copied URL is reachable for whoever copies it.

Drop the URL text from the card and label the copy action, which was an
unlabeled icon that did not say it copies a URL. Name the API section
Individual MCP APIs so it is clear those are MCP endpoints too.

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

* Move MCP disable action into a header overflow menu

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

* Align MCP card header actions and drop the disable icon

Put the help and overflow buttons in the header row so they line up with
each other and the title, instead of positioning them absolutely.

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

* Rename MCP card row to Your MCP API

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

* Drop trailing word from MCP card description

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

* Put configure before copy and drop Calendar from the demo

Ordering the configure button first lines the copy buttons up across all
rows. Calendar is not an LLM API integrations register, so mock only
Assist and Music Assistant.

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

* Always show the MCP configure button

The options flow is now in core, so the entry always supports options.

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

* Tell users where to add the MCP URL

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

* Link the MCP docs from the card description

Point at the integration documentation for agent-specific instructions,
and call them AI agents.

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

* Drop the MCP card help icon

The description links the documentation, so the header icon is a second
link to the same page.

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

* Show MCP URLs in outlined rows

Give each URL its own outlined row with the URL under the name, and copy
with an icon button, so the URLs are visible before copying.

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

* Use hassUrl for MCP URLs and show load errors

Build the URLs with the standard helper instead of the window location.

Catch a failing load and show an alert. Without it a failed load left
the card with neither the URLs nor the enable button.

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

* Show a spinner while the MCP card loads

Without it the card showed only its description until the config entries
arrived.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 08:38:21 +03:00
renovate[bot] 74967ef6ee Update dependency generate-license-file to v4.2.5 (#53929)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-02 08:23:09 +03:00
Maarten Lakerveld 46de23727f Remove ES5 specific code (#53899)
* Remove web components polyfills and ES5 custom elements adapter

All supported browsers (legacy floor: Chrome 59, Safari/iOS 12, Firefox 94)
have native shadow DOM and custom elements, so the webcomponents bundle,
ShadyCSS branch, and lit polyfill-support are unreachable. The legacy build
now emits ES2017 classes, so custom-elements-es5-adapter and the
window.loadES5Adapter hook (no known third-party consumers) are removed.
Also stops shipping the never-loaded dialog-polyfill css and drops both
now-unused dependencies.

* Remove keyed-es5 Terser workaround

The custom keyed directive existed because Terser with ecma: 5 miscompiled
the destructured update() parameters (#28732). The legacy build now minifies
with ecma: 2017, so the stock lit-html keyed directive works in both builds.

* Remove old-browser JS shims and stale ES5 build references

Drops the IE-only navigator.msMaxTouchPoints check, replaces the
toggleAttribute helper with the native method (polyfilled automatically for
Chrome < 69 in the legacy build), and removes babel excludes for the
uninstalled proxy-polyfill and unfetch packages. Updates comments that
still described the legacy build as ES5.

* Remove vendor prefixes for no-longer-supported browsers

Deletes -ms- prefixes (IE/EdgeHTML only) and -webkit-/-moz- prefixed
declarations that every supported browser understands unprefixed, or that
Lightning CSS re-adds automatically from the unprefixed property in
production builds. Blocks that only had prefixed user-select now use the
standard property (previously Firefox got no user-select there at all).
Converts the four -webkit-linear-gradient() declarations - the sole
gradient syntax on those sliders - to standard linear-gradient().

* Remove dead html_url custom panel support

html_url pointed to an HTML Import, a Polymer-era feature removed from
Chrome in 2019 and never shipped elsewhere. The loader has not handled the
html type for years (it fell through to a rejection), and core's
panel_custom integration no longer accepts the option, so the branch was
unreachable. Also drops the ha-panel-${name} legacy tag naming that was
keyed on html_url.

* Repair list-plugins-and-polyfills script for Babel 8

The audit script died at startup since the Babel 8 update: preset-env no
longer exposes lib/debug.js (logPlugin is now inlined locally, built on the
public getInclusionReasons helper) and babel-plugin-polyfill-corejs3 v1 no
longer ships lib/shipped-proposals.js (list inlined).

Instead of invoking the preset with a hand-mocked plugin API - the part
that kept drifting - the plugin listing now runs a real transform of an
empty file with preset-env in debug mode, declaring the same caller
capabilities as babel-loader. The polyfill listing now passes the
configured core-js version to core-js-compat, mirroring the provider's own
filtering so the report cannot list modules the installed core-js lacks.
Output is byte-identical to the direct-invocation approach. Also documents
the script in the build-scripts README.

* Correct macOS floor for Safari 26 in companion app UA regex

Safari 26 ships for macOS 14 Sonoma and 15 Sequoia, not only macOS 26, so
the SAFARI_TO_MACOS entry breaking the minimum-supported-macOS pattern
would have sent updated macOS 14/15 companion apps to the legacy build
once the modern floor reaches Safari 26.

* Remove orphaned values left behind by vendor prefix removal

* Require macOS 14.6 for Safari 26 in companion app UA regex
2026-09-02 08:15:36 +03:00
Paul Bottein dc5d9f48bb Migrate add-to action list to grouped list style (#53921) 2026-09-01 16:28:30 +02:00
Paul Bottein 3d3cdabfe7 Split more-info related and details views (#53920)
* Split more-info related and details views

* Update the related view e2e smoke case
2026-09-01 16:17:37 +02:00
Paul Bottein 4b1f12c0ef Add seek support to the browser media player (#53917) 2026-09-01 14:44:21 +02:00
a9d7712fb2 Load the base map through core's tile proxy (#53845)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-01 10:26:18 +02:00
Krisjanis Lejejs a03efc1c5d Use the cloud auto-login flow in the voice satellite wizard (#53854)
Replace the wizard's own registration and sign-in screens with the cloud
panel's, extracted into cloud-register-card, so signing up mid-wizard gets
email-confirmation auto login instead of the legacy register plus login
polling. The wizard's copy of the login error ladder goes with it.

Drive the registration state from cloud/status rather than from the cloud
events, which carry nothing it does not already hold. The waiting view now
clears when the pending registration does, so a restart no longer leaves it
spinning on a registration the backend has forgotten.

Depends on the auto-login state fix in home-assistant/core#180423: without
it a retry loop that gives up before the register view stores its controller
leaves cloud/status reporting a pending registration with no reason, which
this no longer reads from the event instead.
2026-09-01 10:04:56 +03:00
Paulus SchoutsenandClaude Opus 5 9f7c353ad0 Group the connectivity panels behind a Connectivity settings page (#53905)
* Group the connectivity panels behind a Connectivity settings page

Move the connectivity panels (Matter, Zigbee, Z-Wave, KNX, MQTT, Thread,
Bluetooth, serial, infrared, radio frequency, Insteon, and tags) behind a new
Connectivity page. It sits with Voice assistants in the second settings group,
where those panels used to be listed individually.

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

* Update config panel e2e coverage for the Connectivity page

The connectivity panels moved off the settings root, so split the link smoke
cases and assert them on the Connectivity page instead. Add a route smoke case
for /config/connectivity.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-01 08:37:57 +03:00
lalexdotcom 350fae4107 Report the fallback camera preview as loaded in ha-camera-stream (#53810)
`ha-camera-stream` declares a `load` event and both of its video players fire it through `fireEvent(this, "load")`, but the MJPEG and snapshot fallback `<img>` never does. Every camera without a working stream therefore goes through the one path that stays silent.

`hui-image` listens for that event to record `_lastImageHeight`, and that value is what drops the `.ratio` class so the container can size to its content. Without it the container keeps the 16:9 padding box it falls back to before anything is measured, while the picture is drawn at its own ratio and overflows. Measured against a 600x410 camera in a 600px wide card: the container stays 337.5px tall while the picture is drawn 410px tall, and it is still so ten seconds after the card is built.

Firing the event from the fallback image settles it: the container becomes 410px, matching the picture, and the 16:9 fallback goes back to being a placeholder for the first frame instead of a permanent state. Cameras that do have a stream are unaffected — they already fire `load` from their player.
2026-08-31 13:36:48 +00:00
Maarten LakerveldandCopilot Autofix powered by AI e09c4084b7 Timer UI enhancements (#53793)
* Add timer data helpers for formatter-based display and duration serialization

Refactor computeDisplayTimer to take formatEntityState instead of hass so
context-migrated components can reuse it, add finishes_at to TimerEntity,
and add durationDataToTimerString for serializing duration input values.

* Modernize timer more-info dialog with state header and live countdown

Use ha-more-info-state-header with a ticking remaining-time display and
the standard more-info control layout. Adds timer to
DOMAINS_WITH_NEW_MORE_INFO, replacing the legacy state-card row.

* Add timer-actions and timer-presets card features

timer-actions shows start/pause/cancel/finish buttons (finish opt-in)
with state-aware disabling; start becomes restart while active.
timer-presets shows configurable one-tap durations that call timer.start.
Timer tile suggestions default to the timer-actions feature.

* Pulse timer red when it finishes

When a timer runs out or timer.finish is called, the tile icon and the
more-info countdown pulse red twice. Cancelling does not pulse: the new
timerJustFinished helper distinguishes the transitions via the
last_transition attribute. Honors prefers-reduced-motion.

* Apply suggestions from code review

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

* Send normalized duration for timer presets

Presets are parsed leniently by createDurationData like other duration
inputs, but the raw config value was sent to timer.start. A malformed
preset such as 1:nope:00 rendered as 1 h yet was rejected by core. Store
the normalized seconds so the label and the service call always match.

* Store timer presets on the entity instead of the card config

Review feedback: presets belong to the timer entity, like cover favorite
positions and light favorite colors. They now live in the entity registry
options and are edited in the more info dialog through the shared favorites
UI, which also brings reset and copy to other timers. The tile feature keeps
its style option but reads the presets from the registry.

* Reject a zero duration when adding or editing a timer preset

A zero duration passed the form's required check but was then treated as a
cancel, so saving the prefilled 0:00:00 silently did nothing. Form dialogs
now take an optional submit-time validate hook that blocks the submit and
shows a field error, and the preset dialog uses it to require at least one
second.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-31 14:12:26 +03:00
nokside 946722daa2 Optimize update entities computation in hui-updates-card (#53878) 2026-08-31 12:28:18 +03:00
karwostsandPetar Petrov 3b038d5372 Support multiple for media selector (#53815)
* Support multiple for media selector

* Update src/components/ha-selector/ha-selector-media.ts

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

* add to UI

* merge fix

* add helper

* Modify media item picker for clearable and image upload

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-31 12:02:03 +03:00
Paul Bottein 6b7d387175 Fix weather forecast tabs in the more info dialog (#53841) 2026-08-30 19:52:50 +02:00
renovate[bot] 8a4202ea19 Update dependency lint-staged to v17.4.1 (#53887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:47:46 +02:00
renovate[bot] 2eaf1112f2 Update dependency @rspack/core to v2.2.1 (#53886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:47:26 +02:00
dependabot[bot] d30ff07394 Bump the codeql-action group across 1 directory with 2 updates (#53884)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 09:59:04 +03:00
renovate[bot] 5616b52979 Update dependency js-yaml to v5.4.1 (#53883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 07:55:36 +03:00
Maarten Lakerveld 4219288701 Fix log entry details collapsing to an ellipsis on narrow screens (#53881)
The secondary line of system log entries was made inline-block in #53575
to apply text direction. An inline-block is an atomic inline, so the
parent's text-overflow: ellipsis hid the whole line whenever it did not
fit, leaving only "…" on mobile. Use unicode-bidi: isolate instead so
direction still applies while the text truncates normally.

Fixes #53879
2026-08-29 21:54:56 +02:00
nokside 09b221cf45 Fix date, datetime, and color temperature initial values (#53863) 2026-08-29 16:21:44 +03:00
renovate[bot] 89a467cac7 Update dependency @rspack/core to v2.2.0 (#53872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 07:15:53 +00:00
Brandon c29b693038 Make the media selector clear button follow image_upload (#53866) 2026-08-29 09:14:05 +02:00
renovate[bot] 7ab178d045 Update Node.js to v24.20.0 (#53871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:08:01 +02:00
renovate[bot] 538523c58b Update dependency generate-license-file to v4.2.4 (#53868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:07:39 +02:00
136cf0c8bf Revamp user settings page & navigation (#52070)
* refactor(profile): split general section into subpages with dashboard navigation

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

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

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

* refactor: remove orphan translation keys after general section split

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

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

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

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

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

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

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

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

* fix: security subpage width and card spacing

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

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

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

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

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

* refactor(profile): rename profile subpage labels

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

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

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

Update Appearance description to reflect its new scope.

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

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

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

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

* add title to theme settings card

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

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

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

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

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

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

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

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

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

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

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

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

* Fix test:e2e:demo

* fix broken links

* Add my links

* Use s instead of smal for ha-button size

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

---------

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

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

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

* Create zha-network-data.test.ts

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

* Add test coverage for extended relationship priority

* Apply suggestions from code review

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

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

* Update zha-network-data.ts

* Update zha-network-data.test.ts

* Update zha-network-data.ts

* Update zha-network-data.ts

* Update zha-network-data.test.ts

---------

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

* Fix unsupported selector initial values

* Omit undefined choose child values

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

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

Fixes #53851

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

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

* fix: remove translation file

* refactor: rename show_addfab to show_add_event

* fix: css calendar height

* feat: add event styling options

* Delete translations/backend/en.json

* fix: translation file

* fix: translations file

* fix: no box shadow when button below calendar

* fix: inconsistent narrow mode calculation

* fix: screen reader compability

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

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

* fix: use literal union instead of string in editor

* fix: use sentence case

* Apply suggestions from code review

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

* fix: remove unused class

* feat: use visible parameter to toggle editor elements

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

* fix: lint type error

* fix: editor defaults

---------

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

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

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

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

* Use configured gas entity display precision in energy distribution card

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

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

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

* Respect gas display precision in energy sources table

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

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

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

* Use highest gas display precision for energy distribution

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

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

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

* Use highest gas display precision for energy distribution

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

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

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

* Use highest gas display precision in gas graph

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

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

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

* Use display_precision for entity precision

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

* Use display_precision for entity precision

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

* Use display_precision for entity precision

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

* Use display_precision for entity precision

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

* Use display_precision for entity precision

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

* Use display_precision for entity precision

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

* Restore consumption formatting in energy distribution card

* Respect gas display precision in energy sources table

* Respect gas display precision in energy graph

* Respect display precision in formatConsumptionShort

* Pass gas display precision to consumption formatter

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

* Use gas display precision for graph y-axis

* Update distribution card on gas precision changes

* Update sources table on gas precision changes

* Update gas graph when display precision changes

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

* Update distribution card on gas unit changes

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

* Derive gas display precision with a getter

* Fix hass change handling in energy sources table

* Fix hass change handling in gas graph

* Fix hass change handling in energy distribution

* Fix gas unit narrowing in energy distribution

* Fix formatting in gas graph card

Refactor gasDisplayPrecisions assignment for better readability.

* Fix formatting in energy sources table

* Fix Prettier formatting in energy gas cards

---------

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

* Apply suggestions from code review

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

* Fix formatting

---------

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

Fixes #52901
2026-08-28 08:45:28 +01:00
renovate[bot] cde2e66229 Update dependency typescript-eslint to v8.68.0 (#53848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 06:45:08 +02:00
Paulus Schoutsen 71d5500ed8 Render camera favorites as camera tiles (#53814) 2026-08-28 00:05:02 +02:00
renovate[bot] 462321a3c8 Update dependency eslint to v10.9.1 (#53846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:51 +02:00
renovate[bot] afc0c36dd9 Update dependency marked to v18.0.11 (#53847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:43 +02:00
Bram Kragten 7f86074e07 Revert "Load the base map through core's tile proxy"
This reverts commit 4b9e47079c.
2026-08-27 19:15:16 +02:00
Bram KragtenandClaude Opus 5 4b9e47079c Load the base map through core's tile proxy
Core now proxies both vector and raster tiles, which is what lets them be
requested with an application User-Agent and without a referrer - a
browser can set neither. So the frontend stops talking to OpenStreetMap
and CARTO directly and goes through /api/map_tiles.

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

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

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

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

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

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

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

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

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

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

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

* Apply suggestions from code review

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

---------

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

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

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

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

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

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

* Use the normal create path when remounting itemId pages

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

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

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

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

* Fetch neighbors in bulk and hide them behind a toggle

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

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

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

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

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

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

* Use callWS for in-flight request ownership

* Avoid mutating shared request results

* Deep-freeze shared in-flight request results

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

---------

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

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

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

* Tidy unsaved-changes guard tests

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

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

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

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

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

Mean mode no longer averages markers in as zero.
2026-08-26 15:29:12 +03:00
Petar Petrov 7a387004d6 Cache media source resolutions for view backgrounds (#53615)
Cache resolved media source URLs for view backgrounds
2026-08-26 15:28:34 +03:00
Paul Bottein 1e9993785d Add reusable device class picker (#53798) 2026-08-26 13:26:32 +02:00
renovate[bot] 1a3a93e3b9 Update dependency cropperjs to v1.6.3 (#53796) 2026-08-26 11:33:32 +01:00
Jan Bouwhuis c6dd741bb5 Add device class selector (#53672)
* Add sesnor device class selector

* Remove unused constants

* Remove stale test case and import

* Fix CI tests

* Add domain and make selector generic

* Follow up comments

* Use import
2026-08-26 12:23:26 +02:00
Krisjanis Lejejs 24f522b7ae Improve cloud login/sign up pages, add auto login (#53790)
* Improve cloud login/sign up pages, add auto login

* Improve trual button text

* Improve event subscription, fix email wrapping
2026-08-26 05:17:59 -04:00
Petar Petrov 0594229a45 Fix choose-view dialog showing another dashboard's views (#53792)
* Show the selected dashboard's views in the choose-view dialog

* Block the choose-view action while the dashboard config loads
2026-08-26 12:08:06 +03:00
Josef Zweck 615fd58324 Allow cross domain translations for config entry setup errors (#53791) 2026-08-26 09:35:48 +01:00
Paul Bottein fb35194041 Share favorites editor and security entity filter (#53785) 2026-08-26 09:11:48 +03:00
Paul Bottein d4e3ec858e Remove pulse from security dashboard alerts (#53784) 2026-08-26 08:24:31 +03:00
renovate[bot] 6516b8de68 Update dependency @bundle-stats/plugin-webpack-filter to v4.22.3 (#53783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 18:10:10 +02:00
Paul Bottein edb2f98d03 Show targets in automation traces (#53718) 2026-08-25 18:50:04 +03:00
Aidan Timson 5ae95a1ea2 Add favorites to Security dashboard (#53512)
* Add favorites to security dashboard

* Use contexts in favorites editor

* Update src/translations/en.json

Co-authored-by: Paul Bottein <[email protected]>

* Address security favorites review

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:46:03 +02:00
Aidan Timson d0371e506f Add active alerts to Security dashboard (#53031)
* Add security dashboard alerts

* Fix security alerts editor and card defaults

* Use consistent security editor icons

* Fix security dashboard alert state

* Fix lint

* Render security alerts as individual cards

* Use context data in security alerts editor

* Add pulse for alerts

Co-authored-by: Paul Bottein <[email protected]>

* Use explicit cover and lock alert states

* Fix test

* Remove security panel rendering tests

* Use complete states

Co-authored-by: Paul Bottein <[email protected]>

* Remove camera

Co-authored-by: Paul Bottein <[email protected]>

* open state only

Co-authored-by: Paul Bottein <[email protected]>

* Remove security entity filter cache

* Format

* Update security alert visibility tests

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-25 17:28:46 +02:00
Paul Bottein 872205c352 Remove template and default-value tests (#53779) 2026-08-25 17:14:00 +03:00
tormazs bf939babfd Fix ha-control-select losing its value when activated by a screen reader (#53777)
The option handlers read the value from ev.target, which is whichever
element the click landed on. Real pointer events are retargeted to the
outer role="radio" element by .option .content { pointer-events: none },
but a screen reader's synthetic activation click is not hit-tested, so it
lands on the inner content element, which has no value.

ev.currentTarget is always the element the listener is bound to, so it
always carries the value.
2026-08-25 15:39:05 +03:00
nokside d41b21b9d6 Fix timeCachePromiseFunc cache handling (#53773)
Fix promise cache result handling
2026-08-25 15:31:08 +03:00
Jan Bouwhuis 7e495f38d2 Expand sensor entity constants and add device classes (#53748)
* Expand sensor entity constants and add device classes

* Updated constants

* Fix typo

* Fix name

* Restore part original generated script comment

* Add convertible units and fix null type

* Fix type

* Update constants

* Fix import SENSOR_NUMERIC_DEVICE_CLASSES
2026-08-25 14:28:13 +02:00
Petar Petrov 5cff0cb4c9 Focus nested form-dialog fields when a nested level is pushed (#53757)
* Focus nested form-dialog fields after a nested level is pushed.

Host focus on ha-form does not pierce selector shadows, so keyboard
focus stayed on the opener instead of the nested controls.

* Simplify nested form-dialog focus and fix CI types.

Wait one render and focus the first real control instead of waiting for
custom-element upgrades or walking a general focusable tree.

* Wait for lazy selector upgrades before nested form focus.

nextRender is not enough on a cold selector chunk; wait for undefined
custom elements so nested focus does not depend on network timing.
2026-08-25 15:14:33 +03:00
Paul Bottein dbe2e2c079 Reflect the active filters in the target picker counts (#53716) 2026-08-25 13:53:26 +02:00
Paul Bottein e22b770252 Clarify when agents should add tests (#53775) 2026-08-25 12:28:47 +01:00
Paul Bottein fd388d99bf Show area and disable pulse by default in alert card (#53774) 2026-08-25 12:17:52 +01:00
Aidan Timson f9bfc66d5b Add alert card (#53758)
* Add security alerts card

* Fix security alert cards

* Validate security alert card configuration

* Keep a single generic alert card

* Use entity context in alert card
2026-08-25 10:45:53 +01:00
renovate[bot] c4ab017aed Update dependency @rspack/dev-server to v2.2.1 (#53772) 2026-08-25 09:27:59 +01:00
Paul Bottein e22ef3e1f3 Merge the domain and device class filters into a type filter (#53710)
* Merge the domain and device class filters into a type filter

* Only scan the entities while the type filter panel is open
2026-08-25 08:12:00 +02:00
renovate[bot] 7a3764790b Update dependency @types/luxon to v3.7.5 (#53770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 08:40:20 +03:00
karwosts c499c2e030 Fix more-info setting dirty state tracking (#53762) 2026-08-25 07:02:41 +02:00
Raman GuptaandClaude Opus 5 13ec52ddbe Fix Z-Wave add node security strategy selection not sticking (#53769)
The security strategy step rendered ha-form with a data object built
from a @state field that was never assigned, so the form was always
bound to an undefined strategy. ha-form's own optimistic data update
made the radio look selected after a click, but the next re-render of
the step (triggered by any hass update) re-committed the undefined
value and cleared the selection.

Make the step a controlled component: the dialog already owns
_inclusionStrategy and resets it when navigating back, so pass it down
as a property instead of keeping a second, unused copy in the child.


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

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:01:47 +02:00
wollewandkarwosts d288cfd30a Add supported_speeds parameter for cover movement actions (#53572)
add supported_speeds for cover service actions

Co-authored-by: karwosts <[email protected]>
2026-08-24 20:10:19 +02:00
karwosts 643e8934b4 Allow media selector to optionally select a media directory (#27873)
Allow media browser to select a directory
2026-08-24 20:05:21 +02:00
renovate[bot] 9f3ca39738 Update dependency eslint to v10.9.0 (#53765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 17:03:14 +03:00
renovate[bot] 8cee5155e6 Update dependency @rsdoctor/rspack-plugin to v1.6.3 (#53761) 2026-08-24 14:37:06 +01:00
Paul Bottein 15341a0aad Show climate summary card with only temperature sensors (#53760) 2026-08-24 14:51:04 +02:00
Petar Petrov 9f8a2253ac Add keyboard and screen reader navigation to the energy device charts (#53754)
* Sonify value-first charts now that the extension reads them

* Announce device names instead of statistic ids in sonified charts
2026-08-24 14:45:43 +02:00
Maarten Lakerveld 97df5a5bf0 Key more-info details memoize on attribute name formatter (#53755)
_getDetailData caches on stateObj alone while formatting via this.hass,
so results go stale when translation-based format functions reload (e.g.
backend translations finishing after open, or a language switch) until
the entity next changes state. Pass hass.formatEntityAttributeName as an
extra memoize argument so the cache invalidates when format functions
are recreated.
2026-08-24 11:59:22 +02:00
Petar Petrov 19cd06bb8c Fix energy period rollover overnight and in the first hour (#53717)
* Fix energy dashboard staying on yesterday after midnight.

* Catch up the energy live day before subscribe fetches.

* Keep energy day math in the server timezone so DST cannot skip a live day.

Browser-local addDays can jump a calendar day on a 23-hour DST fallback.
Assert against tz-internal endOfDay/addDays under Europe/Berlin so UTC CI
catches a regression, and prove the 01:00 timer and catch-up refresh fetch
the live day rather than only updating collection.start.

* Don't follow the live day from midnightRollover alone.

A stored non-today preset would otherwise be discarded on the first subscribe. Drop tautological UTC DST tests that cannot fail.
2026-08-24 10:16:00 +02:00
nokside fcca87b368 Fix editing nested multiple object selector items (#53709)
* Fix editing nested multiple object selectors

* Fix nested form dialog close and focus restoration

Cancel all pending nested form levels when the physical dialog closes, and restore focus to the opener after nested submit or cancel.

Add regression coverage for both behaviors.

* Fix selector object test types
2026-08-24 09:41:58 +03:00
puddly 2eb3b025d8 Add a serial panel (#53699)
* WIP: Initial attempt

* Arrange into sections

* Clean up UI

* Add descriptions

* Align with Core changes

* Address review feedback and add an info dialog

* Combine websocket commands into one

* Drop unnecessary info dialog rows

* Add a status bar

* Expose `resolved_device` as well
2026-08-24 06:58:23 +02:00
DawnWang 43628c5d69 Fix error badge creation on cold load (#53752) 2026-08-24 04:53:53 +00:00
renovate[bot] 0f52c00821 Update dependency echarts-extension-chart2music to v0.1.1 (#53753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 06:48:24 +02:00
dependabot[bot] 20f70cde7a Bump the codeql-action group across 1 directory with 2 updates (#53744)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 09:55:54 +02:00
renovate[bot] b38d7bcb26 Update dependency hls.js to v1.7.1 (#53741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-22 22:20:30 +02:00
Petar Petrov 0572d71699 Replace a remembered yesterday energy period when picking now (#53735) 2026-08-22 17:36:40 +02:00
renovate[bot] bcd13fcaf7 Update vitest monorepo to v4.1.11 (#53732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 21:35:37 +02:00
renovate[bot] 308eac97ee Update dependency marked to v18.0.10 (#53725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 06:37:46 +02:00
Paul Bottein 6da851219d Fix stacked toasts when a dashboard is updated in another session (#53719) 2026-08-20 21:34:08 +02:00
renovate[bot] 970be43c3d Update dependency intl-messageformat to v11.2.14 (#53722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 21:01:27 +02:00
karwosts 8bb718a486 Cleanup some attributes code (#53713) 2026-08-20 20:54:59 +02:00
Josef Zweck 0e0bc389f8 Show result of action for button presses (#53645)
* Show result of action on button feature

* Add button entity

* Add to button entities

* add to tile icon

* Delay for spinner

* Remove result indicator from tile icon
2026-08-20 16:49:55 +02:00
Petar Petrov 70a482e0ed Add keyboard and screen reader navigation to charts (#53533)
* Add keyboard and screen reader navigation to charts via Chart2Music

* Mark charts as busy while the sonification chunk loads

* Skip chart sonification when there is nothing to navigate

* Drop the direct chart2music dependency

* Exclude legend-hidden series when judging chart sonifiability

* Push statistics chart times as numbers so sonification reads real dates
2026-08-20 16:55:11 +03:00
Maarten Lakerveld 5a0db4a796 Show Assist greeting in the assistant's language (#53707)
The greeting sits in the assistant's chat bubble, so render it in the pipeline's language instead of the interface language. Falls back to the interface language when no translation is available (issue #53703).
2026-08-20 12:49:40 +02:00
622f3e97e6 Add RepairsFlow as a next_flow from a RepairsFlow (#51744)
* fix flowType in config subentry flows/next_flow

* suggested changes

* Repair Flow Next Flow Repair

* Add repair flow support a new repair flow as the next_flow

* Add support for the next_flow being another repair flow

* fix lint errors

* Reviewer response and fix linter error

* Revert to generics to avoid multiple casts

* Move show-dialog-repair-flows to dialogs

* Update src/dialogs/config-flow/dialog-data-entry-flow.ts

* Apply suggestions from code review

* Apply suggestion from @MindFreeze

* format

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Paulus Schoutsen <[email protected]>
2026-08-20 11:24:23 +03:00
renovate[bot] 3091362040 Update dependency @html-eslint/eslint-plugin to v0.65.0 (#53714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 11:08:20 +03:00
Paul Bottein 3c7560a4e2 Add indeterminate and controlled selection to lists (#53706) 2026-08-19 23:26:40 +02:00
Petar PetrovandSimon Lamon a9e178cb0e Show per-mount storage usage on the storage page (#53683)
* Show per-mount storage usage on the storage page

Each active mount now fetches its own usage from the Supervisor and renders
a bar with a used-of-total line under its address. The requests are not
awaited, so a slow or unreachable server cannot hold up the rows, and a
failed one simply leaves that row without usage.

fetchHostDisksUsage now takes a disk and an optional max_depth. The data
disk callers keep their depth of 3; mounts send no depth at all, since
walking one costs a round trip per directory and the Supervisor already
has a sensible per-target default.

* Vertically align ha-bar

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-08-19 21:18:37 +02:00
23b83fbd27 Update browserlist (#53691)
* update browserlist for better packaging

* add results of bundle changes

* update browserlist, budgets and ecma terser rewrite level

* remove results.md used for testing

* Apply suggestion from @silamon

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-08-19 18:45:22 +00:00
Marcin BauerandClaude Opus 5 62849e2630 Fix outlined button border colors (#53704)
The brand outline pointed at a token that doesn't exist, so CSS fell back
to the neutral border and brand outlined buttons drew a gray ring.

Disabled outlined buttons only reset their background and label, keeping
the variant-coloured border.

Also add outlined to the gallery: it has always been a valid appearance
but was missing from the documented list the gallery grid is built from,
which is why both bugs went unnoticed.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:37:09 +02:00
renovate[bot] aa3e11f6e9 Update CodeMirror (#53705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 20:34:12 +02:00
Paul BotteinandPetar Petrov 82ed13b7fc Fix filter pane expand animation and use a bottom sheet in narrow mode (#53684)
* Fix filter pane expand animation and use a bottom sheet in narrow mode

* Update src/common/controllers/filter-panel-controller.ts

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

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 16:18:40 +03:00
Petar Petrov 089592e7e3 Ignore stale History statistics fetches after the selection changes (#53700)
* Ignore stale History statistics fetches after the selection changes.

A slower fetchStatistics call could finish after a newer sources or date-range request and replace the charts the user is looking at.

* Update src/panels/history/ha-panel-history.ts

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-19 12:00:43 +02:00
Paul Bottein 4bc9b2be7b Remove the unused compact mode from the target picker (#53685) 2026-08-19 11:48:43 +03:00
renovate[bot] 1778b7abff Update dependency barcode-detector to v3.2.2 (#53702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 11:39:58 +03:00
90567a491a Redesign History and Activity filtering into a sources pane (#53630)
* Redesign History and Activity filtering into a sources pane

Give both panels the toolbar + left pane layout of the data tables, and
merge target picking and filtering into one "Sources" surface, per the UX
discussion.

- ha-filter-pane: pane on wide screens, bottom sheet on narrow ones,
  mirroring the filter pane of hass-tabs-subpage-data-table
- ha-sources-picker: target picker plus domain, device class and
  integration filters, shared by both panels
- ha-filter-device-classes: new filter panel, labelled with the backend
  device class names
- ha-filter-pane-chip and ha-empty-state: the chip with a filter count
  badge and the centered placeholder both panels need
- ha-date-range-nav: the date range picker as one pill with previous and
  next steppers, for the toolbar
- History draws timeline names above their bar (inside-labels), which
  gives long names the full width
- Activity keeps a floating date header while scrolling, and reset moved
  into the overflow menu next to refresh and download
- Drop the now unused compact and add-on-top modes of ha-target-picker,
  including ha-target-picker-value-chip
- Right-align the clear button in the domains filter header, matching the
  other filter panels

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

* Remove dead code breaking the type check

* Fix stuck loading state when the selection resolves to no entity

* Keep the target picker out of the redesign

* Remember the source filters across visits

* Truncate timeline names that do not fit the plot

* Show device class icons in the sources filter

* Open the sources pane by default on wide screens

* Show the entity count in the sources chip

* Count the same entities in the sources chip and the target picker

* Fix sources resolution, fetching and filter badge in History and Activity

* Update src/panels/history/ha-panel-history.ts

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Paul Bottein <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 11:31:10 +03:00
Paul Bottein 409e2b315f Skip entities assigned to another area when resolving area targets (#53693) 2026-08-19 08:46:45 +03:00
Petar Petrov eb1f8c1fab Add Matter network topology visualization page (#53247)
* Add Matter network topology visualization page

* Add central Home Assistant node to Matter network graph

* Refine Matter graph HA node: theme-aware memoization, drop dead branch

* Render Matter 'unknown' link strength as a present, neutral link

* Pass devices to getDeviceArea so bridged nodes inherit their area

* Distinguish position-unknown Matter graph anchors and name border routers

Anchors to a hubless component only mean the node is reachable, so draw
them dotted instead of reusing the solid line that marks a real path
through a border router or access point.

Also prefer the border router mDNS host name over vendor and model,
which several vendors report identically on every unit.

* Name Matter access points by SSID and show their radio address

Access points were labelled with a BSSID, and the same address was
repeated as the node context. Prefer the SSID for the label and keep the
radio address as context only when it differs, so the radios of one mesh
stay distinguishable without repeating the label.

Falls back to today's BSSID against servers that do not send an SSID.

* Colour Matter graph links by transport and float unknown neighbours

Unknown Thread neighbours are not commissioned on our fabric, so Home
Assistant has no operational path to them. A group made only of unknown
devices no longer draws an anchor to the Home Assistant node and floats
instead of claiming a connection that does not exist.

Link colour now encodes the transport, purple for Thread and pink for
Wi-Fi, with the signal level left on the line width and spelled out in
the tooltip. A dead link keeps the disabled colour, which is the only
thing separating it from a healthy weak one now that both are width 1.
The edge tooltip names the network, since the graph legend describes
nodes only and cannot carry a link entry.

* Float Matter nodes whose route to Home Assistant is unknown

Only border routers and access points get an edge to the Home Assistant
node, because only those are a path we can actually see. A node in a
group with neither was previously anchored to Home Assistant with a
dotted line, which reads as a physical connection that was never
observed.

Removing the anchor makes the component walk that picked a
representative dead code, so it goes too.

* Match the dashboard's line semantics for Matter graph links

Dash an edge whose endpoint is inferred rather than commissioned, or is
offline, which is the same pair of conditions the matter.js dashboard
dashes on. Stop drawing a link whose every direction is dead: the
summary strength is the strongest direction, so none means a stale
neighbour entry, and the dashboard never draws one either.

That also retires the grey dead-link colour, since such links no longer
reach the graph.

* Carry the transport colour onto the Home Assistant edges

An edge from Home Assistant to a hub now takes the colour of the network
behind it, so one transport reads as one colour the whole way back
instead of changing hue at the hub.

Wi-Fi moves from pink to orange: pink sat close to the error red used
for offline nodes, while orange is far from both that red and the Thread
purple.

* Give the Wi-Fi access point node its transport colour

The access point wore indigo while everything else on its network was
orange. Deriving the category colour from the same helper the links use
keeps a hub and its links one hue, and gives the Wi-Fi colour a legend
entry it did not have before.

* Give the border router node its transport colour too

Both hub categories now derive their colour from the same helper as the
links, so a network is one hue from Home Assistant through its hub to
the devices, and each legend swatch keys the links of that transport.

The border router also gains contrast: the shared Thread purple is
legible on both the light and the dark card background, where the deep
purple it replaces was not.
2026-08-19 08:02:49 +03:00
Paul Bottein 29cf30fc1a Fix check for updates toasts stacking (#53696) 2026-08-19 06:45:52 +02:00
renovate[bot] f88535c446 Lock file maintenance (#53698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 06:43:45 +02:00
Jan Bouwhuis b9fd639bb7 Add improved mqtt panel to config section (#53653)
* Add mqtt config dashboard shortcut

* Improve MQTT subscribe panel formatting and add copy button

* Standardize MQTT options flow button to a nav card style

* Refactor MQTT dashboard

* Improve style

* Add link to broker connection settings

* Test the effect of not exporting the SVG data

* Move MQTT svg icon to separate file
2026-08-18 19:00:28 +02:00
Paul Bottein 2925dfc14e Show entity name in template autocomplete info panel (#53695) 2026-08-18 18:54:03 +02:00
Yosi Levy bb7ec34532 Addons RTL fix (#53692)
Addon RTL fix
2026-08-18 17:01:41 +02:00
Stefan AgnerandClaude Fable 5 89015bc135 Fix backup progress segment order to match backend execution order (#53652)
* Fix backup progress segment order to match backend execution order

The segmented backup progress bar showed Apps, Media, Home Assistant,
but Supervisor backs up Home Assistant first since 2024 (supervisor#5203),
then add-ons, then folders. The Home Assistant segment activated first
while Apps/Media incorrectly showed as completed, and the bar regressed
once the apps stage started.

Also move the await-addon-restart stages into the last creation group,
since Supervisor awaits add-on restarts after the backup file is
finished, keeping the progress bar monotonic.

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

* Keep await restart stages out of MEDIA_STAGES

Give them their own constant and merge them into the last creation
group only where the ordering constraint applies.

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

* Group backup setup stages with the first progress segment

addon_repositories/app_repositories (and docker_config on older
Supervisors) are emitted while the backup is initialized, before the
home_assistant stage, so grouping them with the apps segment made the
bar regress at the start of a backup.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 13:52:07 +03:00
Petar Petrov 343f1e840a Fix Z-Wave network visualization showing wrong route edges (#53676)
Fix repeater mapping and route direction in Z-Wave network graph
2026-08-18 08:07:24 +03:00
G Johansson 1369f80eb0 Allow using description in create_entry for repair flows (#53671) 2026-08-17 22:08:57 +02:00
Paul Bottein cd52f7b193 Fix the clear button alignment in the domains filter (#53687) 2026-08-17 17:47:35 +00:00
renovate[bot] 86dea86b6c Update dependency js-yaml to v5.3.0 (#53679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 17:16:53 +00:00
Paul Bottein 04035016b0 Extract the data table filter chip into ha-filter-pane-chip (#53682) 2026-08-17 19:16:46 +02:00
Petar Petrov 52b0b46991 Group sankey devices by count as well as by value (#53680)
* Group sankey devices by count as well as by value

The 0.1%-of-total value threshold only catches devices that are a
negligible share of the home total. A breaker panel with dozens of
similar-sized circuit clamps sits entirely above it, so nothing groups and
every node collapses toward the 1px minimum with labels shrinking to match.

Cap the number of named children per parent too, defaulting to 20 (the
readable capacity of the default 400px card). Over-cap devices are grouped
into the same per-parent "Other" node the value threshold already produces,
so the flow arithmetic is unchanged, and the cap is configurable per card
via max_devices.

* Clarify the max_devices label is per upstream device, not per floor or area

* Make max_devices budget named devices, not nodes

A cap of 20 rendered 19 devices because a slot was reserved for the Other
node, and a cap of 1 rendered none. The option now means what its name and
its namesake on the devices graph mean: at most this many devices are shown
by name, with Other as overhead on top.

* Extract the sankey device cap into findDevicesOverCap

Moves the count-cap logic out of buildSankeyDeviceNodes into an exported
function that takes the graph it needs, so it can be unit tested directly,
and trims the commentary to the two non-obvious rules (why the whole subtree
is grouped, and why at least two devices are).
2026-08-17 19:14:48 +02:00
Stefan AgnerandClaude Fable 5 d5389b5bef Fall back to option key for untranslated repair menu options (#53681)
Repair fix flows build their menu from the suggestions the backend
reports. A suggestion this frontend has no translation for yet (e.g. a
newer Supervisor offering a new repair suggestion) rendered as an
empty, unlabeled menu entry. Show the raw option key instead, matching
the fallback the form step header already uses.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:23:02 +02:00
Logan RosenandCopilot App f828c636a2 Fix Energy configuration back navigation (#53574)
* Fix Energy configuration back navigation

Co-authored-by: Copilot App <[email protected]>

* Align app E2E navigation with production

Co-authored-by: Copilot App <[email protected]>

---------

Co-authored-by: Copilot App <[email protected]>
2026-08-17 12:45:08 +03:00
Petar Petrov e91337b320 Log console error when a dashboard resource fails to load (#53675)
* Log console error when a dashboard resource fails to load

* Include the rejection reason in the resource load error
2026-08-17 10:04:51 +02:00
karwosts 8ab7c118a9 Fix custom weather icons (#53667) 2026-08-17 06:52:16 +02:00
karwosts 0eafb1f4b4 Add themes to yaml reload menu (#53673) 2026-08-17 06:03:12 +02:00
neyzandkarwosts 9a7722a02e Remove weather card pointer cursor when it has no action (#53654)
* Remove weather card pointer cursor when it has no action

* use hasAnyAction helper

---------

Co-authored-by: karwosts <[email protected]>
2026-08-16 09:38:05 -07:00
renovate[bot] 3e184eeb39 Update dependency @rspack/core to v2.1.10 (#53663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:37:16 +02:00
renovate[bot] 61be4e2952 Update dependency @rsdoctor/rspack-plugin to v1.6.2 (#53662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:13:08 +02:00
Paul Bottein 315cf97e56 Prevent the sidebar from scrolling the dashboard (#53644) 2026-08-16 06:27:47 +00:00
karwosts 771a8aa4c9 Fix negative button of duration selector (#53646) 2026-08-16 06:27:17 +00:00
G JohanssonandSimon Lamon d9aa649854 Remove deprecated battery props from vacuum (#53005)
Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:26:54 +00:00
karwostsandSimon Lamon 7a087cd6d0 Ability to link a person to an existing user (#53550)
* Support linking a person to an existing user

* Don't delete preexisting user on cancel submit

* retry ci

* Don't show choice when no floating users exist

* collapse to single dialog

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:25:03 +00:00
renovate[bot] 32a30468bb Update dependency globals to v17.10.0 (#53647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:24:56 +00:00
karwosts 0ace3cedfb Add disabled to button group (#53651) 2026-08-16 06:24:33 +00:00
karwosts aba7478e26 Fix choose selector minor issues (#53648) 2026-08-16 06:24:28 +00:00
renovate[bot] c8b2e5160a Update dependency hls.js to v1.6.18 (#53649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:21:52 +00:00
dependabot[bot] 4c46ca0918 Bump the codeql-action group across 1 directory with 2 updates (#53661)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 06:20:49 +00:00
Logan RosenandCopilot App 0483106f61 Restore bundle size budget headroom (#53659)
Co-authored-by: Copilot App <[email protected]>
2026-08-16 08:16:29 +02:00
Paul Bottein 7e0c572f69 Replace automation state color with a badge (#53627)
* Replace state color with a badge in automation picker

* Fix data table spacing and text truncation on mobile
2026-08-14 15:31:14 +02:00
renovate[bot] e4438a20cb Update dependency @rspack/core to v2.1.9 (#53642) 2026-08-14 14:13:06 +01:00
Aidan Timson ec418498a7 Make date picker navigation arrows themeable (#53639) 2026-08-14 16:05:46 +03:00
8122baca00 Add more deep links to more-info dialogs (#53628)
* Add deep links to more-info dialogs

* Fix

* Preserve existing URL hash in createMoreInfoUrl and removeMoreInfoUrl

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

* Preserve explicit empty hash and move more-info restoration to app owner

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

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-14 16:02:58 +03:00
Paul Bottein 79738dd257 Add activity detail dialog (#53362)
* Add activity detail dialog

* Apply review fixes to activity detail dialog

* Resolve previous state from history in logbook detail dialog

* Disable text selection on clickable logbook rows

* Document run discovery as a fallback for missing context ids

* Scope the detail chain to the subject's cause path

* Replace clickable logbook rows with explicit detail affordances

* Show the cause badge on automation and integration rows

* Map scheduled and Home Assistant causes to their trigger icons

* Replace logbook detail affordances with full-width rows

* Frame the activity detail dialog with ha-grouped-list

* Add missing import

* Fix milliseconds placement in activity chain times
2026-08-14 15:57:03 +03:00
Bram Kragten f23c8f0e23 Fix sub device tree indicator (#53638) 2026-08-14 09:18:06 +01:00
Bram Kragten 91c28c2f58 Bumped version to 20260729.7 2026-08-14 09:59:26 +02:00
Paul Bottein 1f299d98e0 Fix clipped focus ring on media player playback buttons (#53632) 2026-08-14 09:58:33 +02:00
Petar Petrov a74926aef6 Fix sensor card graph footers not receiving hass on lazy upgrade (#53576) 2026-08-14 09:58:32 +02:00
Bram Kragten 437a4cedf9 Check for wheel on PyPI simple index instead of JSON API (#53549) 2026-08-14 09:58:31 +02:00
Bram Kragten 55913d7505 Fix panels missing updates (#53548) 2026-08-14 09:58:30 +02:00
Paulus SchoutsenandClaude 0bb654c70c Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-14 09:58:30 +02:00
Petar Petrov f578cb0526 Guard registry lookups in the target picker item row (#53637) 2026-08-14 09:55:54 +02:00
renovate[bot] 78878897cc Update dependency typescript-eslint to v8.67.0 (#53634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 06:46:42 +02:00
Petar PetrovandSimon Lamon 9472d8ad12 Add device context to energy individual device names (#53603)
Add device context to energy individual device labels

Co-authored-by: Simon Lamon <[email protected]>
2026-08-13 18:52:28 +02:00
Paul Bottein 94cbe4467b Lay out inline features in two columns (#53631)
* Lay out inline features in two columns

* Detect stacked features from the slot

* Rename tile container layout classes

* Rename the below features in the layout helper

* Keep area card features below at full width

* Stretch the area card features below the inline one

* Keep area card features compact outside the compact type

* Revert "Keep area card features compact outside the compact type"

This reverts commit 48268cb4e2.

* Reapply "Keep area card features compact outside the compact type"

This reverts commit e10bb27e2f.

* Keep the inline feature compact on condensed cards
2026-08-13 18:00:47 +03:00
Paul Bottein 043abb69af Keep control select in the tab order without a selection (#53633) 2026-08-13 15:54:59 +01:00
Paul Bottein b6b8fe641f Fix clipped focus ring on media player playback buttons (#53632) 2026-08-13 15:54:49 +01:00
Bram KragtenandClaude Opus 4.8 70bb08b101 Child devices UI (#53619)
* Add child devices UI

Surface child devices throughout the config UI so they read as first-class
devices nested under their parent:

- Device page: a "Sub-devices" card lists a device's children, and a child's
  page shows a "Part of <parent>" link (hardware/model/config-entry are
  already inherited from the parent by the registry resolver).
- Integration page: children are nested and indented under their parent
  device in the config-entry and subentry device lists.
- Device picker: children are ordered and indented under their parent with a
  tree connector, mirroring the area/floor picker.
- Naming: the device picker's secondary label and search now include the
  parent device name for children, so they stay identifiable in flat views.

Follow-up to the child devices data layer (#53617).

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

* Add child devices to demo and gallery mock data

A power strip parent with two outlet children in the demo device stubs and
the ha-selector gallery demo, so child device rendering (nesting, tree
indentation, parent-context naming, inherited area) can be exercised without
a running backend.

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

* Refine child devices UI and target resolution

Follow-up polish so child devices behave like a normal device everywhere:

- Targets & filters: a parent device qualifies for and resolves to its
  children's entities (getDevices filter, deviceMeetsFilter,
  deviceMeetsTargetSelector, resolveEntityIDs and the target-chip "split into
  entities" expand), matching core's server-side target resolution. Selecting a
  parent excludes its children from the picker.
- Pickers: keep a parent visible when a child matches the search (device and
  target pickers), fix the target picker's nested order (unsorted search +
  recomputed last-child flag), and render the sub-device tree in the target
  picker's device group.
- Area/naming: the area field shows only the (inherited) area again; the parent
  name remains a search term.
- Integration page: correct the tree end connector and align it in narrow mode.
- A parent-disabled child can no longer be enabled from the settings dialog.
- Devices dashboard: show "Part of <parent>" under a sub-device's name and add a
  hidden-by-default, searchable and groupable Parent device column that groups a
  parent together with its children.

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

* Address review on child devices UI

Align target resolution with core and fix picker/table details:

- Only a directly targeted device expands to its child devices. Labels are
  never inherited by children (core's target helper is explicit about this) and
  areas resolve by effective-area membership, so deviceMeetsFilter and
  deviceMeetsTargetSelector evaluate a device's own entities again.
- Add devicesInEffectiveArea, mirroring core's dr.async_entries_for_area: an
  area contains its devices plus children that inherit the area, but not a child
  with a different explicit area. Used for area expansion, area matching and the
  area chip's split action.
- Device picker: add a searchFn that restores the nested parent/child order
  after the fuzzy search and recomputes the last visible child, so a child can
  no longer be ranked above its parent and connectors stay correct.
- Devices dashboard: derive the family group name from the family's parent
  device with the same fallback for parents and children, so an unnamed parent
  cannot end up in a different group than its children.
- Child devices card: pass the device registry so a child shows its inherited
  area.

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

* Fix formatting in device picker row renderer

Reindent the row renderer template after it gained a block body, so Prettier
is satisfied.

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

* Make the child device tree connector span the full row

The connector was a fixed 48px box centred in a taller two-line row, and its
SVG was letterboxed by the default preserveAspectRatio, so the dashed line
stopped short of the row edges and consecutive children never visually
connected.

Let the indicator stretch (preserveAspectRatio="none") and give it the full row
height, so the line runs edge to edge with the elbow at the vertical centre.
non-scaling-stroke keeps the line width and dash pattern identical however far
it is stretched; existing 48x48 usages render unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 17:45:31 +03:00
JulianHockandClaude Opus 5 4259ddee24 Show remaining tile card features below when position is inline (#53310)
* Show remaining tile card features below when position is inline

Previously the tile card silently dropped every feature after the first
one when "features_position" was set to "inline". The first feature is
now rendered next to the name as before, and any additional features are
stacked underneath, the same way they are in "bottom" position.

The tile container gained a "features-bottom" slot for this, and the
card size and grid options now account for the extra rows.

* Add tests for hui-tile-card size and grid options calculations

* Enhance hui-tile-card to support inline feature layout and improve feature counting logic

* reduce vertical gap for inline features

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-13 15:45:00 +02:00
6c5ac1ea84 Add background editor to dashboard detail editor (#51644)
* Setup

* Use same slot layout

* Fill tabs

* Move content rendering to dedicated function

* Self review

* Save dashboard details before background

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

* Protect strategy dashboard config

* Apply dashboard background review fixes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-13 16:27:34 +03:00
Petar Petrov 5224fa2048 Seed selector values when enabling optional service fields (#53629) 2026-08-13 15:27:07 +02:00
Bram Kragten 235541748b Bumped version to 20260729.6 2026-08-07 16:30:37 +02:00
Paul Bottein 6bd1d698ce Fix disabled save button when leaving an automation with unsaved changes (#53538) 2026-08-07 16:27:35 +02:00
Bram Kragten 18862b868f Wait with showing pending http changes dialog until loading screen is… (#53534)
* Wait with showing pending http changes dialog until loading screen is gone

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

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

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

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

---------

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

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

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

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

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

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

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

* Use unknown instead of any in the CachedCollection shape

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

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

---------

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

* Apply suggestions from code review

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

---------

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

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

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

* Use top-level slugify websocket command

* Fix formatting

---------

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

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

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

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

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

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

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

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

* Align vertical tile icons in grid sections

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-07-30 10:41:10 +02:00
731 changed files with 55348 additions and 13292 deletions
+27 -3
View File
@@ -1,12 +1,18 @@
---
name: ha-frontend-review
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
description: Home Assistant frontend PR and review guidance, including implementation quality, existing review feedback, established design patterns, and the authority of UI/UX evidence. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
---
# HA Frontend Review
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Review Preparation
1. Establish the review scope from the diff and the behavior changed by it.
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
@@ -22,7 +28,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- Each test added by the change protects real logic, not the look of a component.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
@@ -92,9 +98,27 @@ Configuration and props:
- Keep APIs extensible without adding speculative abstractions.
- Validate configuration before applying changes.
## UI/UX Evidence
For user-facing changes, establish the existing design context as part of frontend review:
- Treat an applicable gallery specification as the authoritative repository source for the documented component or interaction. Inspect its written guidance and demos when present.
- Note relevant production designs, approved designs, and shared `ha-*` components so the implementation can also be compared with established frontend behaviour.
- Require the applicable gallery documentation or demo to change when the implementation intentionally changes behaviour documented there.
- Prefer an established input layout or appropriate shared component over a raw input or ad hoc control.
- Treat explicit UI/UX approval, current repository guidance, and direct human or workflow instructions as stronger evidence than inference.
- Treat linked tasks as evidence of the problem and stated requirements, not automatic UI/UX approval.
- When the direction remains uncertain, search frontend pull requests that carry or previously carried **Needs UX**. Inspect their label history, comments, and reviews rather than relying on the current label alone.
- Judge relevant feedback by its content and surrounding discussion. When GitHub provides `author_association`, prioritise feedback marked `MEMBER`.
- Prefer recent feedback about similar interactions or components. Label removal can show that a workflow gate moved on, but does not prove approval without the surrounding discussion.
- Summarise the applicable guidance instead of maintaining a reviewer list or treating one historical decision as a permanent rule.
## Review Flow
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
+6 -5
View File
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
## When To Add Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
@@ -0,0 +1,44 @@
---
name: ha-frontend-ux-readiness
description: Home Assistant frontend UI/UX readiness routing for new cards, badges, screens, and interaction patterns. Use it with `ha-frontend-review` to decide whether frontend review is enough or further UI/UX input is needed. Skip it for PRs already carrying Needs UX and for fixes or technical details that preserve an established experience.
---
# HA Frontend UI/UX Readiness
Use the design context and evidence gathered through `ha-frontend-review` to decide whether a change's UI/UX direction is settled and whether further UI/UX review should be recommended or is explicitly required. This skill routes the result; it does not repeat or replace implementation, accessibility, or frontend quality review.
## Early Exit
If the PR already carries the **Needs UX** label, reviewers have already decided to ask designers or UX reviewers for guidance. Do not use this skill to make that recommendation again.
## Triage
1. Apply `ha-frontend-review` and record its UI/UX evidence.
- Include the applicable gallery specification, production or approved designs, shared `ha-*` components, task requirements, historical feedback, and direct instructions.
- Keep the implementation review separate from this readiness decision.
2. Classify the user-facing decision.
- Existing-feature fixes and technical details normally remain with frontend review when they preserve the established experience.
- New cards, badges, screens, and interaction patterns normally need this readiness check.
3. Decide whether the evidence gathered during frontend review settles the direction.
- Frontend maintainer guidance, explicit UI/UX approval, current guidance, an established pattern, or a task that settles the material visual and interaction choices can settle the direction.
- Insufficient or conflicting evidence leaves the direction unresolved.
4. Select exactly one route below and state which frontend-review evidence supports it.
## Routes
- **Frontend review sufficient:** The change preserves the established experience, or the evidence settles the material visual and interaction choices. Cite that evidence and continue with `ha-frontend-review`.
- **UI/UX review recommended:** A new user experience has material design choices that remain unresolved. Identify the decision that needs input and recommend UI/UX review.
- **UI/UX review required:** A workflow rule or direct human instruction explicitly requires UI/UX review. Cite that evidence. A new design, task link, or lack of evidence is not enough on its own.
Frontend maintainer guidance, explicit UI/UX approval, established patterns, and task requirements inform the route. A task can settle requirements without settling the UI/UX. Recommend UI/UX review when important design choices remain unresolved.
## Reporting
Keep the result concise and use this structure:
- **Route:** Frontend review sufficient, UI/UX review recommended, or UI/UX review required
- **Established patterns:** relevant gallery specifications, production designs, and shared `ha-*` components for the next reviewer to compare
- **Supporting evidence:** approval, guidance, established patterns, task requirements, direct instructions, or the evidence gap or conflict
- **Next action:** the review action implied by the route
Separate whether the UI/UX direction is settled from whether the implementation is correct.
+40 -24
View File
@@ -1,36 +1,52 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - released in the last 2 years + current alpha/beta versions
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 1 year
Firefox ESR
>= 0.5%
last 2 years
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05%
not dead
not op_mini all
# Legacy builds are served when modern requirements are not met.
# Floors are pinned explicitly (not usage-based) so the support policy is
# deliberate and does not drift with global usage statistics, which do not
# represent old tablets and wall displays used as Home Assistant dashboards:
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
# also covers old kiosk browsers and no-longer-updating webviews above it.
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
# enterprise installs, whose engines can run the legacy build fine.
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
# If Firefox ever becomes the pin forcing extra output, raise it first.
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
Chrome >= 59
ChromeAndroid >= 59
Edge >= 79
Firefox >= 94
FirefoxAndroid >= 94
iOS >= 12
Safari >= 12
Samsung >= 9
[legacy-sw]
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
Chrome >= 59 and supports serviceworkers
ChromeAndroid >= 59 and supports serviceworkers
Edge >= 79 and supports serviceworkers
Firefox >= 94 and supports serviceworkers
FirefoxAndroid >= 94 and supports serviceworkers
iOS >= 12 and supports serviceworkers
Safari >= 12 and supports serviceworkers
Samsung >= 9 and supports serviceworkers
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"
@@ -0,0 +1,47 @@
name: Sync device class constants
# Mirrors the device class constants for Home Assistant Core's into the
# build-time default in src/data/devce_classes.ts and opens a PR
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # Daily, 04:00 UTC
permissions:
contents: read
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/sync-numeric-device-classes
commit-message: Update numeric sensor device classes
title: Update numeric sensor device classes
body: |
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
`SensorDeviceClass`.
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
@@ -1,7 +1,8 @@
name: Sync numeric device classes
name: Sync sensor entity constants
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
# units and related device and state classes arrays into the
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
# when it drifts. Reads homeassistant/generated/sensor.json from core.
on:
@@ -28,8 +29,8 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
+1
View File
@@ -74,3 +74,4 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.19.0
24.21.0
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -33,10 +33,11 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
## Project Skills
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
@@ -46,7 +47,8 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
+9 -1
View File
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
- Minify HTML
- Bundle multiple imports so that the browser can fetch less files
- Generate a second version that is ES5 compatible
- Generate a second version that is compatible with older browsers (legacy build)
Configuration for all these steps are specified in [bundle.js](bundle.js).
## Auditing browser support changes
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
environment (modern/legacy), the Babel transforms preset-env enables and the
Core-JS polyfills that may be injected — as collapsible markdown ready to
paste into a PR. Use it to show the bundle impact when changing
`.browserslistrc` or the Babel configuration.
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
"app": 576583,
"core": 54790,
"authorize": 543547,
"onboarding": 655556
},
"frontend-legacy": {
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
"app": 717124,
"core": 181583,
"authorize": 699175,
"onboarding": 816133
}
}
+5 -5
View File
@@ -63,8 +63,10 @@ module.exports.htmlMinifierOptions = {
};
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
safari10: !latestBuild,
ecma: latestBuild ? 2015 : 5,
// Highest syntax the minifier may emit; it never downlevels. Every browser
// in [modern] is well past ES2020 (universal since spring 2020); the
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
ecma: latestBuild ? 2020 : 2017,
module: latestBuild,
format: { comments: false },
sourceMap: !isTestBuild,
@@ -119,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// Import helpers and regenerator from runtime package.
// Import helpers from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
@@ -153,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
"@lit-labs/virtualizer/polyfills",
"@webcomponents/scoped-custom-element-registry",
"element-internals-polyfill",
"proxy-polyfill",
"unfetch",
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
+2 -2
View File
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
26: [14, 6, 0],
};
const getCommonTemplateVars = () => {
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
...htmlMinifierOptions,
conservativeCollapse: false,
minifyJS: terserOptions({
latestBuild: false, // Shared scripts should be ES5
latestBuild: false, // Shared scripts must satisfy the legacy targets
isTestBuild: true, // Don't need source maps
}),
}).then((wrapped) =>
+17 -41
View File
@@ -4,6 +4,7 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -41,37 +42,6 @@ function copyMdiIcons(staticDir) {
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
}
function copyPolyfills(staticDir) {
const staticPath = genStaticPath(staticDir);
// For custom panels using ES5 builds that don't use Babel 7+
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
staticPath("polyfills/")
);
// Web Component polyfills and adapters
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
staticPath("polyfills/")
);
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
staticPath("polyfills/")
);
// Lit polyfill support
fs.copySync(
npmPath("lit/polyfill-support.js"),
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
);
// dialog-polyfill css
copyFileDir(
npmPath("dialog-polyfill/dialog-polyfill.css"),
staticPath("polyfills/")
);
}
function copyFonts(staticDir) {
const staticPath = genStaticPath(staticDir);
// Local fonts
@@ -89,7 +59,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
function copyMapPanel(staticDir) {
async function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -103,6 +73,16 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -132,14 +112,13 @@ gulp.task("copy-static-app", async () => {
const staticDir = paths.app_output_static;
// Basic static files
fs.copySync(polyPath("public"), paths.app_output_root);
copyPolyfills(staticDir);
copyFonts(staticDir);
copyTranslations(staticDir);
copyLocaleData(staticDir);
copyMdiIcons(staticDir);
// Panel assets
copyMapPanel(staticDir);
await copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -154,8 +133,7 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -167,8 +145,7 @@ gulp.task("copy-static-cast", async () => {
fs.copySync(polyPath("public/static"), paths.cast_output_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -184,7 +161,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
copyMapPanel(paths.gallery_output_static);
await copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -214,8 +191,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
gulp.task("gen-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const domainDeviceClasses = data ?? {};
if (!Object.keys(domainDeviceClasses).length) {
throw new Error(`No device classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with \`script/gen_device_classes\`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
${Object.entries(domainDeviceClasses)
.map(
([domain, deviceClasses]) =>
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
@@ -0,0 +1,83 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_entity_constants.ts"
);
gulp.task("gen-sensor-entity-constants", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
const deviceClassUnits = data.device_class_units ?? {};
const convertibleClassUnits = data.convertible_units ?? {};
const stateClasses = data.state_classes ?? [];
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
!stateClasses.length ||
!Object.keys(deviceClassUnits).length ||
!Object.keys(stateClassUnits).length
) {
throw new Error(
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
);
}
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_sensor_entity_constants\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
${Object.entries(deviceClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
${Object.entries(convertibleClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
+3 -1
View File
@@ -9,10 +9,12 @@ import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-numeric-device-classes.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
+86
View File
@@ -0,0 +1,86 @@
// Generates the MapLibre styles for the vector base map.
//
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
// is what lets them be requested with an application User-Agent and without a
// referrer. The styles stay here because they come from @versatiles/style and
// core has no node toolchain to regenerate them with.
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
const outputDir = path.resolve(paths.build_dir, "map");
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// the proxy move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
// The builder can only write a tile URL, so the source is repointed afterwards.
// Keyed on there being exactly one source: any other shape means the builder's
// own default host would ship unnoticed.
const useTileJson = (name, style) => {
const sources = Object.values(style.sources);
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${PROXY_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
await Promise.all(
// Both themes up front: dark is a real cartography, not an inverted raster.
[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) =>
writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(addLatinLabels(useTileJson(name, builder(styleOptions))))
)
)
);
};
// Shared so it does not have to be wired into every pipeline separately.
let pending;
export const ensureMapAssets = () => {
pending ??= buildMapAssets();
return pending;
};
gulp.task("build-map-assets", ensureMapAssets);
export const mapAssetsDir = outputDir;
+42
View File
@@ -0,0 +1,42 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+62 -27
View File
@@ -1,38 +1,55 @@
#!/usr/bin/env node
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
import { version as babelVersion } from "@babel/core";
import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import { transformSync } from "@babel/core";
import compilationTargets, {
getInclusionReasons,
} from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
const detailsOpen = (heading) =>
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
const detailsClose = "</details>\n";
const dummyAPI = {
version: babelVersion,
// eslint-disable-next-line @typescript-eslint/no-empty-function
assertVersion: () => {},
caller: (callback) =>
callback({
name: "Dummy Bundler",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
}),
targets: () => ({}),
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
// longer exposes (the package rolls up into lib/index.js and exports nothing
// but the preset). Prints an item with the targets that require it.
const logPlugin = (item, targetVersions, list) => {
const filteredList = getInclusionReasons(item, targetVersions, list);
const support = list[item];
if (!support) {
console.log(` ${item}`);
return;
}
let formattedTargets = `{`;
let first = true;
for (const target of Object.keys(filteredList)) {
if (!first) formattedTargets += `,`;
first = false;
formattedTargets += ` ${target}`;
if (support[target]) formattedTargets += ` < ${support[target]}`;
}
formattedTargets += ` }`;
console.log(` ${item} ${formattedTargets}`);
};
// Copied from babel-plugin-polyfill-corejs3's generated
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
// the package's rolled-up bundle).
const shippedProposalsList = new Set([
"esnext.array.group",
"esnext.array.group-to-map",
"esnext.iterator.zip",
"esnext.iterator.zip-keyed",
"esnext.symbol.metadata",
]);
// Generate filter function based on proposal/method inputs
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
if (proposals || method === "entry-global") return true;
if (shippedProposals && shippedPolyfills.default.has(name)) {
if (shippedProposals && shippedProposalsList.has(name)) {
return true;
}
if (name.startsWith("esnext.")) {
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets[0][1];
const presetEnvOpts = babelOpts.presets.find(
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
)?.[1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Invoking preset-env in debug mode will log the included plugins
// Transforming an empty file with preset-env in debug mode logs the included
// plugins. The caller declares the same capabilities babel-loader does, so
// plugins gated on bundler support (e.g. transform-export-namespace-from)
// match the build.
presetEnvOpts.debug = true;
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
presetEnv.default(dummyAPI, {
...presetEnvOpts,
browserslistEnv,
debug: true,
transformSync("", {
...babelOpts,
configFile: false,
filename: "audit.js",
caller: {
name: "list-plugins-and-polyfills",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
},
});
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
const targets = compilationTargets(babelOpts.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
// `version` limits the list to modules the installed core-js ships,
// mirroring the provider's own filtering.
const polyfillList = coreJSCompat({
targets,
version: corejsOpts.version,
}).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
+1 -4
View File
@@ -345,10 +345,7 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
+1 -3
View File
@@ -15,7 +15,6 @@ import {
saveTokens,
} from "../../../../src/common/auth/token_storage";
import { atLeastVersion } from "../../../../src/common/config/version";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
toggleAttribute(
this,
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
+4
View File
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_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";
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
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.
hass.addEntities(connectivityEntities());
// Let the new registries and entities reach the dashboard before saving the
// config, so dashboard strategies generate against them
+11 -1
View File
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
connectivityComponents,
connectivityEntities,
connectivityEntityRegistryEntries,
} from "./stubs/connectivity/fixtures";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
...connectivityCommands,
];
@customElement("ha-demo")
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
],
},
});
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
created_at: 0,
modified_at: 0,
},
...connectivityEntityRegistryEntries,
]);
hass.addEntities(energyEntities());
hass.addEntities(connectivityEntities());
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
+8
View File
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockConnectivity } from "./connectivity";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockCloud(hass);
mockConfig(hass);
mockConfigEntries(hass);
mockConnectivity(hass);
mockDeviceAutomation(hass);
mockEntitySources(hass);
mockBlueprint(hass);
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
hass.mockWS("llm/api/list", () => ({
apis: [
{ id: "assist", name: "Assist" },
{ id: "music_assistant", name: "Music Assistant" },
],
}));
};
+13
View File
@@ -5,6 +5,7 @@ import type {
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
import type { IntegrationType } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityConfigEntries } from "./connectivity/fixtures";
const baseEntry = {
source: "user",
@@ -19,6 +20,7 @@ const baseEntry = {
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
@@ -79,6 +81,17 @@ export const demoConfigEntries: {
title: "Comfort level",
},
},
{
type: "service",
entry: {
...baseEntry,
entry_id: "mock-mcp-server",
domain: "mcp_server",
title: "Assist, Music Assistant",
supports_options: true,
},
},
...connectivityConfigEntries,
];
const filterEntries = (filters?: {
@@ -0,0 +1,71 @@
import { manifest } from "../../manifest";
import { configEntry, device } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
const ADAPTER_ENTRY_ID = "mock-bluetooth";
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
export const bluetoothFixtures: ConnectivityFixtures = {
components: ["bluetooth"],
commands: ["bluetooth/"],
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(
ADAPTER_ENTRY_ID,
"bluetooth",
`hci0 (${LOCAL_SOURCE})`,
{ source: "usb", supports_options: true }
),
},
{
type: "hub",
entry: configEntry(
PROXY_LIVING_ENTRY_ID,
"bluetooth",
"Living room proxy",
{ source: "esphome" }
),
},
{
type: "hub",
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
source: "esphome",
}),
},
],
// Adapters and proxies are matched to their scanner by the bluetooth
// connection tuple, see ./mock.
devices: [
device(
"bluetooth-hci0",
"hci0",
"Home Assistant",
"Home Assistant Green",
ADAPTER_ENTRY_ID,
{ connections: [["bluetooth", LOCAL_SOURCE]] }
),
device(
"bluetooth-proxy-living",
"Living room proxy",
"Espressif",
"ESP32-C3",
PROXY_LIVING_ENTRY_ID,
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
),
device(
"bluetooth-proxy-shed",
"Shed proxy",
"Espressif",
"ESP32",
PROXY_SHED_ENTRY_ID,
{ connections: [["bluetooth", SHED_SOURCE]] }
),
],
};
@@ -0,0 +1,193 @@
import type {
BluetoothAllocationsData,
BluetoothDeviceData,
BluetoothScannerDetails,
BluetoothScannerState,
} from "../../../../../src/data/bluetooth";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
const SCANNERS: BluetoothScannerDetails[] = [
{
source: LOCAL_SOURCE,
connectable: true,
name: "hci0 (Home Assistant Green)",
adapter: "hci0",
scanner_type: "usb",
},
{
source: PROXY_SOURCE,
connectable: true,
name: "Living room proxy",
adapter: "esp32",
scanner_type: "remote",
},
{
source: SHED_SOURCE,
connectable: false,
name: "Shed proxy",
adapter: "esp32",
scanner_type: "remote",
},
];
const SCANNER_STATES: BluetoothScannerState[] = [
{
source: LOCAL_SOURCE,
adapter: "hci0",
current_mode: "active",
requested_mode: "active",
},
{
source: PROXY_SOURCE,
adapter: "esp32",
current_mode: "active",
requested_mode: "active",
},
{
// A proxy configured for passive scanning: it reports advertisements but
// cannot connect, matching its `connectable: false` scanner details.
source: SHED_SOURCE,
adapter: "esp32",
current_mode: "passive",
requested_mode: "passive",
},
];
const ALLOCATIONS: BluetoothAllocationsData[] = [
{
source: LOCAL_SOURCE,
slots: 5,
free: 3,
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
},
{
source: PROXY_SOURCE,
slots: 3,
free: 2,
allocated: ["FC:58:FA:12:34:56"],
},
];
interface DemoAdvertisement {
address: string;
name: string;
rssi: number;
source: string;
connectable?: boolean;
tx_power?: number;
manufacturer_data?: Record<number, string>;
service_data?: Record<string, string>;
service_uuids?: string[];
}
const ADVERTISEMENTS: DemoAdvertisement[] = [
{
address: "A4:C1:38:11:22:33",
name: "Govee H5075",
rssi: -58,
source: LOCAL_SOURCE,
manufacturer_data: { 60552: "000104a10b64" },
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
},
{
address: "E7:2E:00:B1:9A:1C",
name: "SwitchBot Meter",
rssi: -71,
source: LOCAL_SOURCE,
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
},
{
address: "FC:58:FA:12:34:56",
name: "Xiaomi LYWSD03MMC",
rssi: -64,
source: PROXY_SOURCE,
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
},
{
address: "C4:7C:8D:6A:5B:20",
name: "Flower care",
rssi: -88,
source: SHED_SOURCE,
connectable: false,
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
},
{
address: "D0:36:9A:7F:11:80",
name: "Tile Mate",
rssi: -79,
source: PROXY_SOURCE,
connectable: false,
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
},
{
address: "5C:C7:C1:04:9E:2A",
name: "Nut Find 3",
rssi: -93,
source: SHED_SOURCE,
connectable: false,
},
];
const buildAdvertisement = (
advertisement: DemoAdvertisement
): BluetoothDeviceData => ({
address: advertisement.address,
name: advertisement.name,
rssi: advertisement.rssi,
source: advertisement.source,
connectable: advertisement.connectable ?? true,
manufacturer_data: advertisement.manufacturer_data ?? {},
service_data: advertisement.service_data ?? {},
service_uuids: advertisement.service_uuids ?? [],
tx_power: advertisement.tx_power ?? -59,
time: Date.now() / 1000,
raw: null,
});
// Nudge the signal strength a little on every tick so the monitors and the
// network map look alive without the rows jumping around.
const jitter = (rssi: number) =>
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
export const mockBluetooth = (hass: MockHomeAssistant) => {
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.({ add: SCANNERS }))
);
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
);
hass.mockWS(
"bluetooth/subscribe_connection_allocations",
(msg: { config_entry_id?: string }, _hass, onChange) =>
emitInitial(() =>
onChange?.(
msg.config_entry_id
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
: ALLOCATIONS
)
)
);
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
let advertisements = ADVERTISEMENTS;
const stopInitial = emitInitial(() =>
onChange?.({ add: advertisements.map(buildAdvertisement) })
);
const interval = window.setInterval(() => {
advertisements = advertisements.map((advertisement) => ({
...advertisement,
rssi: jitter(advertisement.rssi),
}));
onChange?.({ change: advertisements.map(buildAdvertisement) });
}, 5000);
return () => {
stopInitial();
clearInterval(interval);
};
});
};
+62
View File
@@ -0,0 +1,62 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { modbusFixtures } from "./modbus/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
import { tagsFixtures } from "./tags/fixtures";
import { threadFixtures } from "./thread/fixtures";
import type { ConnectivityFixtures } from "./types";
import { zhaFixtures } from "./zha/fixtures";
import { zwaveJsFixtures } from "./zwave_js/fixtures";
// Every integration reachable from Settings > Connectivity that has frontend
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
modbusFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
zwaveJsFixtures,
zhaFixtures,
radioFrequencyFixtures,
tagsFixtures,
threadFixtures,
];
const collect = <T>(
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
export const connectivityComponents = collect((f) => f.components);
export const connectivityCommands = collect((f) => f.commands);
export const connectivityConfigEntries = collect((f) => f.configEntries);
export const connectivityManifests = collect((f) => f.manifests);
export const connectivityDevices = collect((f) => f.devices);
export const connectivityEntityRegistryEntries = collect(
(f) => f.entityRegistryEntries
);
export const connectivityEntities = () =>
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
/** Backend translation resources, merged per category. */
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
Record<string, Record<string, string>>
>((resources, fixtures) => {
for (const [category, keys] of Object.entries(
fixtures.backendTranslations ?? {}
)) {
resources[category] = { ...resources[category], ...keys };
}
return resources;
}, {});
+129
View File
@@ -0,0 +1,129 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
// Builders for the registry fixtures, so each integration only spells out what
// makes its own entries different.
const BASE_CONFIG_ENTRY = {
source: "user",
state: "loaded" as const,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
export const configEntry = (
entryId: string,
domain: string,
title: string,
extra: Partial<ConfigEntry> = {}
): ConfigEntry => ({
...BASE_CONFIG_ENTRY,
entry_id: entryId,
domain,
title,
...extra,
});
const BASE_DEVICE = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
entry_type: null,
created_at: 0,
modified_at: 0,
};
export const device = (
id: string,
name: string,
manufacturer: string,
model: string,
entryId: string,
extra: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry => ({
...BASE_DEVICE,
id,
name,
manufacturer,
model,
config_entries: [entryId],
primary_config_entry: entryId,
...extra,
});
const BASE_REGISTRY_ENTRY = {
config_subentry_id: null,
area_id: null,
disabled_by: null,
icon: null,
labels: [] as string[],
categories: {},
hidden_by: null,
entity_category: null,
options: null,
created_at: 0,
modified_at: 0,
};
export const registryEntry = (
entityId: string,
deviceId: string,
entryId: string,
platform: string,
name?: string
): EntityRegistryEntry => ({
...BASE_REGISTRY_ENTRY,
entity_id: entityId,
id: entityId,
unique_id: entityId,
device_id: deviceId,
config_entry_id: entryId,
platform,
name: name ?? null,
has_entity_name: name === undefined,
});
/**
* The demo's `addEntities` builds the display entity registry from the entity
* inputs, so mirror each state's registry entry onto it. Panels count entities
* per device and per integration.
*/
export const withRegistryLinks = (
entries: EntityRegistryEntry[],
states: Record<string, EntityInput>
): EntityInput[] =>
Object.values(states).map((state) => {
const entry = entries.find(
(candidate) => candidate.entity_id === state.entity_id
);
return entry
? { ...state, device_id: entry.device_id!, platform: entry.platform }
: state;
});
export const minutesAgo = (minutes: number) =>
new Date(Date.now() - minutes * 60000).toISOString();
+27
View File
@@ -0,0 +1,27 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockModbus } from "./modbus/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
import { mockThread } from "./thread/mock";
import { mockZha } from "./zha/mock";
import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockBluetooth,
mockSerial,
mockModbus,
mockMatter,
mockMqtt,
mockZwaveJs,
mockZha,
mockRadioFrequency,
mockThread,
];
export const mockConnectivity = (hass: MockHomeAssistant) => {
MOCKS.forEach((mock) => mock(hass));
};
@@ -0,0 +1,107 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-broadlink";
const DEVICES = [
device(
"broadlink-living-room",
"Living room blaster",
"Broadlink",
"RM4 pro",
ENTRY_ID,
{ area_id: "living_room" }
),
device(
"broadlink-bedroom",
"Bedroom blaster",
"Broadlink",
"RM mini 3",
ENTRY_ID,
{ area_id: "bedroom" }
),
];
// The infrared panel is entity driven: the proxy entities live in the
// `infrared` domain while their registry platform stays the integration that
// provides them.
const REGISTRY_ENTRIES = [
registryEntry(
"infrared.living_room_blaster_emitter",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Emitter"
),
registryEntry(
"infrared.living_room_blaster_receiver",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Receiver"
),
registryEntry(
"infrared.bedroom_blaster_emitter",
"broadlink-bedroom",
ENTRY_ID,
"broadlink",
"Emitter"
),
];
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
// A proxy entity's state is the timestamp it was last used.
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"infrared.living_room_blaster_emitter": {
entity_id: "infrared.living_room_blaster_emitter",
state: minutesAgo(12),
attributes: {
friendly_name: "Living room blaster Emitter",
device_class: "emitter",
},
},
"infrared.living_room_blaster_receiver": {
entity_id: "infrared.living_room_blaster_receiver",
state: minutesAgo(3),
attributes: {
friendly_name: "Living room blaster Receiver",
device_class: "receiver",
},
},
"infrared.bedroom_blaster_emitter": {
entity_id: "infrared.bedroom_blaster_emitter",
state: minutesAgo(1440),
attributes: {
friendly_name: "Bedroom blaster Emitter",
device_class: "emitter",
},
},
}),
backendTranslations: {
entity_component: {
// The emitter is the default device class, stored under the "_" key.
"component.infrared.entity_component._.name": "Emitter",
"component.infrared.entity_component.receiver.name": "Receiver",
},
},
};
@@ -0,0 +1,119 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-matter";
const DEVICES = [
device(
"matter-kitchen-light",
"Kitchen ceiling",
"Nanoleaf",
"Essentials A19",
ENTRY_ID,
{ area_id: "kitchen", sw_version: "3.5.7" }
),
device(
"matter-side-door-lock",
"Side door lock",
"Aqara",
"Smart Lock U100",
ENTRY_ID,
{ sw_version: "1.2.0" }
),
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
area_id: "office",
sw_version: "3.2.0",
}),
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
area_id: "garden",
sw_version: "3.2.1",
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.kitchen_ceiling",
"matter-kitchen-light",
ENTRY_ID,
"matter"
),
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
registryEntry(
"sensor.office_plug_power",
"matter-office-plug",
ENTRY_ID,
"matter"
),
registryEntry(
"sensor.patio_temperature",
"matter-patio-sensor",
ENTRY_ID,
"matter"
),
];
export const matterFixtures: ConnectivityFixtures = {
components: ["matter"],
commands: ["matter/"],
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "matter", "Matter", {
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.kitchen_ceiling": {
entity_id: "light.kitchen_ceiling",
state: "on",
attributes: {
friendly_name: "Kitchen ceiling",
supported_color_modes: ["color_temp"],
color_mode: "color_temp",
brightness: 204,
},
},
"lock.side_door": {
entity_id: "lock.side_door",
state: "locked",
attributes: { friendly_name: "Side door lock" },
},
"switch.office_plug": {
entity_id: "switch.office_plug",
state: "on",
attributes: { friendly_name: "Office plug" },
},
"sensor.office_plug_power": {
entity_id: "sensor.office_plug_power",
state: "42.5",
attributes: {
friendly_name: "Office plug power",
device_class: "power",
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.patio_temperature": {
entity_id: "sensor.patio_temperature",
state: "14.2",
attributes: {
friendly_name: "Patio temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
}),
};
+404
View File
@@ -0,0 +1,404 @@
import type {
MatterCommissioningParameters,
MatterFabricData,
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterNodeDiagnostics,
} from "../../../../../src/data/matter";
import { NetworkType, NodeType } from "../../../../../src/data/matter";
import type {
MatterLockInfo,
MatterLockUser,
MatterLockUsersResponse,
SetMatterLockCredentialResult,
} from "../../../../../src/data/matter-lock";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const EXT_PAN_ID = "dead00beef00cafe";
const THREAD_NETWORK = "ha-thread";
const NODES: MatterNetworkTopologyNode[] = [
{
id: "otbr",
kind: "border_router",
network_type: "thread",
ha_device_id: null,
role: "leader",
available: true,
ext_address: "f6a1c30d2b4e5f61",
rloc16: 0x4000,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
host_name: "homeassistant",
vendor_name: "Home Assistant",
model_name: "OpenThread Border Router",
},
{
id: "wifi-ap",
kind: "wifi_ap",
network_type: "wifi",
ha_device_id: null,
available: true,
ssid: "Home",
bssid: "3c:37:86:11:22:33",
vendor_name: "Ubiquiti",
model_name: "U6 Pro",
},
{
id: "node-1",
kind: "matter",
network_type: "thread",
node_id: 1,
ha_device_id: "matter-kitchen-light",
available: true,
role: "router",
ext_address: "10a2b3c4d5e6f708",
rloc16: 0x8401,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Nanoleaf",
model_name: "Essentials A19",
},
{
id: "node-2",
kind: "matter",
network_type: "thread",
node_id: 2,
ha_device_id: "matter-side-door-lock",
available: true,
role: "sleepy_end_device",
ext_address: "20b3c4d5e6f70819",
rloc16: 0x8402,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Aqara",
model_name: "Smart Lock U100",
},
{
id: "node-3",
kind: "matter",
network_type: "wifi",
node_id: 3,
ha_device_id: "matter-office-plug",
available: true,
ssid: "Home",
vendor_name: "Eve",
model_name: "Energy",
},
{
id: "node-4",
kind: "matter",
network_type: "thread",
node_id: 4,
ha_device_id: "matter-patio-sensor",
available: false,
role: "end_device",
ext_address: "30c4d5e6f708192a",
rloc16: 0x8403,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Eve",
model_name: "Weather",
},
{
id: "thread-unknown-1",
kind: "thread_unknown",
network_type: "thread",
available: true,
role: "end_device",
ext_address: "40d5e6f708192a3b",
rloc16: 0x8404,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
},
];
const connection = (
source: string,
target: string,
network: string,
strength: MatterNetworkTopologyConnection["strength"],
lqi?: number,
rssi?: number
): MatterNetworkTopologyConnection => ({
source,
target,
network,
strength,
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
via_route_table: false,
path_cost: null,
});
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
connection("otbr", "node-1", "thread", "strong", 245, -42),
connection("otbr", "node-2", "thread", "medium", 160, -68),
connection("node-1", "node-4", "thread", "weak", 84, -86),
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
];
const TOPOLOGY: MatterNetworkTopology = {
collected_at: Date.now() / 1000,
nodes: NODES,
connections: CONNECTIONS,
};
const buildTopology = (): MatterNetworkTopology => ({
...TOPOLOGY,
collected_at: Date.now() / 1000,
});
const FABRICS: MatterFabricData[] = [
{
fabric_id: 1,
vendor_id: 4939,
fabric_index: 1,
fabric_label: "Home Assistant",
vendor_name: "Home Assistant",
},
];
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
router: NodeType.ROUTING_END_DEVICE,
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
end_device: NodeType.END_DEVICE,
};
const NODES_BY_DEVICE_ID = new Map(
NODES.filter((node) => node.ha_device_id).map((node) => [
node.ha_device_id!,
node,
])
);
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
node.network_type === "thread"
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
: `192.168.1.${100 + (node.node_id ?? 0)}`;
// Diagnostics are derived from the topology so a device's transport,
// availability and node type match the map. The device page reads them per
// device, and gates its actions on `available` and `network_type`.
const buildNodeDiagnostics = (
node: MatterNetworkTopologyNode
): MatterNodeDiagnostics => ({
node_id: node.node_id!,
network_type:
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
node_type: node.is_bridge
? NodeType.BRIDGE
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
network_name: node.network_name ?? node.ssid ?? undefined,
ip_adresses: [nodeIpAddress(node)],
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
available: node.available !== false,
active_fabrics: FABRICS,
active_fabric_index: 1,
});
// The backend resolves the device before acting, so both node commands answer
// the same way when it cannot.
const nodeNotFound = (deviceId: string) =>
Promise.reject({
code: "node_not_found",
message: `No Matter node for device ${deviceId}`,
});
// The manual and QR codes below are the Matter test payload for passcode
// 20202021 with discriminator 3840; the three have to agree.
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
setup_qr_code: "MT:Y.K9042C00KA0648G00",
};
const LOCK_INFO: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 2,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
const initialLockUsers = (): MatterLockUser[] => [
{
user_index: 1,
user_name: "Anne",
user_unique_id: 1,
user_status: "occupied_enabled",
user_type: "unrestricted_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 1 }],
next_user_index: 2,
},
{
user_index: 2,
user_name: "Cleaner",
user_unique_id: 2,
user_status: "occupied_disabled",
user_type: "week_day_schedule_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 2 }],
next_user_index: null,
},
];
// The manage dialog reloads the list after every add, edit and delete, so the
// mocked services keep the lock's users rather than answering from a constant,
// which would make every change look like it was reverted.
const lockUsers = new Map<string, MatterLockUser[]>();
const usersFor = (entityId: string): MatterLockUser[] => {
let users = lockUsers.get(entityId);
if (!users) {
users = initialLockUsers();
lockUsers.set(entityId, users);
}
return users;
};
// Lowest free slot, the way a lock hands out user and credential indexes.
const nextFreeIndex = (taken: number[]): number => {
let index = 1;
while (taken.includes(index)) {
index += 1;
}
return index;
};
export const mockMatter = (hass: MockHomeAssistant) => {
hass.mockWS("matter/network_topology", () => buildTopology());
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(buildTopology()))
);
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
});
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node
? { [nodeIpAddress(node)]: node.available !== false }
: nodeNotFound(msg.device_id);
});
hass.mockWS("matter/interview_node", () => undefined);
// Actions the device page offers for an available node. Without these the
// dialogs behind them fail with `command_not_mocked`.
hass.mockWS(
"matter/open_commissioning_window",
() => COMMISSIONING_PARAMETERS
);
hass.mockWS("matter/remove_matter_fabric", () => undefined);
hass.mockWS("matter/set_wifi_credentials", () => undefined);
hass.mockWS("matter/set_thread", () => undefined);
// The lock device exposes "Manage lock", whose dialog reads back the response
// of these services.
hass.mockService("matter", "get_lock_info", (_data, target) => ({
[target!.entity_id]: LOCK_INFO,
}));
hass.mockService("matter", "get_lock_users", (_data, target) => ({
// Copied, the way a real response would be: the dialog assigns the list to
// reactive state, so handing back the same array leaves it unchanged and
// the list never rerenders.
[target!.entity_id]: {
max_users: LOCK_INFO.max_users!,
users: usersFor(target!.entity_id).map((user) => ({
...user,
credentials: user.credentials.map((credential) => ({ ...credential })),
})),
} satisfies MatterLockUsersResponse,
}));
// Renames the user the credential below created, or edits an existing one.
hass.mockService("matter", "set_lock_user", (data, target) => {
const user = usersFor(target!.entity_id).find(
(candidate) => candidate.user_index === data?.user_index
);
if (user) {
if (data?.user_name !== undefined) {
user.user_name = data.user_name;
}
if (data?.user_type !== undefined) {
user.user_type = data.user_type;
}
if (data?.credential_rule !== undefined) {
user.credential_rule = data.credential_rule;
}
}
return {};
});
hass.mockService("matter", "clear_lock_user", (data, target) => {
const users = usersFor(target!.entity_id);
const index = users.findIndex(
(candidate) => candidate.user_index === data?.user_index
);
if (index !== -1) {
users.splice(index, 1);
}
return {};
});
// Adding a user starts here: the credential creates it, and the dialog reads
// the assigned index straight back to name it.
hass.mockService("matter", "set_lock_credential", (data, target) => {
const users = usersFor(target!.entity_id);
const userIndex =
(data?.user_index as number | null | undefined) ??
nextFreeIndex(
users
.map((user) => user.user_index)
.filter((i): i is number => i !== null)
);
const credentialIndex =
(data?.credential_index as number | null | undefined) ??
nextFreeIndex(
users.flatMap((user) =>
user.credentials
.map((credential) => credential.index)
.filter((i): i is number => i !== null)
)
);
const credential = {
type: (data?.credential_type as string) ?? "pin",
index: credentialIndex,
};
const user = users.find((candidate) => candidate.user_index === userIndex);
if (user) {
user.credentials = [...user.credentials, credential];
} else {
users.push({
user_index: userIndex,
user_name: null,
user_unique_id: userIndex,
user_status: data?.user_status ?? "occupied_enabled",
user_type: data?.user_type ?? "unrestricted_user",
credential_rule: "single",
credentials: [credential],
next_user_index: null,
});
}
return {
[target!.entity_id]: {
credential_index: credentialIndex,
user_index: userIndex,
next_credential_index: null,
} satisfies SetMatterLockCredentialResult,
};
});
};
@@ -0,0 +1,47 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const FLEXIT_ENTRY_ID = "mock-flexit";
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
export const FRONIUS_ENTRY_ID = "mock-fronius";
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("flexit", "Flexit", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "device",
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
},
{
type: "device",
entry: configEntry(
SOLAREDGE_ENTRY_ID,
"solaredge_modbus",
"SolarEdge SE7K"
),
},
{
type: "device",
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
},
],
};
@@ -0,0 +1,32 @@
import type { ModbusConnection } from "../../../../../src/data/modbus";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import {
FLEXIT_ENTRY_ID,
FRONIUS_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "./fixtures";
const CONNECTIONS: ModbusConnection[] = [
// Two integrations on one RS-485 bus, which is what the panel exists to show
{
endpoint: ["serial", RS485_PORT],
connected: true,
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
},
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
units: { [FRONIUS_ENTRY_ID]: [1] },
},
// A second inverter behind a gateway that is not answering
{
endpoint: ["tcp", "modbus-gateway.local", 502],
connected: false,
units: { [FRONIUS_ENTRY_ID]: [2] },
},
];
export const mockModbus = (hass: MockHomeAssistant) => {
hass.mockWS("modbus/connections/list", () => ({ connections: CONNECTIONS }));
};
@@ -0,0 +1,90 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-mqtt";
const DEVICES = [
device(
"mqtt-fridge-sensor",
"Fridge sensor",
"Xiaomi",
"LYWSD03MMC",
ENTRY_ID,
{ area_id: "kitchen" }
),
device(
"mqtt-garage-door",
"Garage door",
"Shelly",
"Shelly Plus 1",
ENTRY_ID
),
];
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.fridge_temperature",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry(
"sensor.fridge_battery",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
];
export const mqttFixtures: ConnectivityFixtures = {
components: ["mqtt"],
// `execute_script` too: the panel publishes through a script action.
commands: ["mqtt/", "execute_script"],
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.fridge_temperature": {
entity_id: "sensor.fridge_temperature",
state: "21.4",
attributes: {
friendly_name: "Fridge temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.fridge_battery": {
entity_id: "sensor.fridge_battery",
state: "92",
attributes: {
friendly_name: "Fridge battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"cover.garage_door": {
entity_id: "cover.garage_door",
state: "closed",
attributes: { friendly_name: "Garage door", device_class: "garage" },
},
}),
};
+192
View File
@@ -0,0 +1,192 @@
import type {
MQTTDeviceDebugInfo,
MQTTMessage,
} from "../../../../../src/data/mqtt";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const PAYLOADS: Record<string, () => string> = {
"homeassistant/status": () => "online",
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
default: () =>
JSON.stringify({
battery: 92,
linkquality: 120,
// Built from whole tenths, so the payload never carries the noise a
// float sum leaves behind, like 21.599999999999998.
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
}),
};
// Subscriptions take a topic filter, but a message carries the topic it was
// actually published on, so a filter has to be resolved to one concrete topic
// before it can be echoed back.
const resolveFilter = (filter: string): string =>
filter
.split("/")
.flatMap((level) => {
if (level === "+") {
return ["kitchen"];
}
if (level === "#") {
return ["kitchen", "temperature"];
}
return [level];
})
.join("/") || "homeassistant/status";
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
topic,
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
qos,
retain: 0,
time: new Date().toISOString(),
});
// A filter matches a topic level by level: "+" stands for one level, "#" for
// the rest of them.
const filterMatches = (filter: string, topic: string): boolean => {
const filterLevels = filter.split("/");
const topicLevels = topic.split("/");
for (let index = 0; index < filterLevels.length; index += 1) {
if (filterLevels[index] === "#") {
return true;
}
if (index >= topicLevels.length) {
return false;
}
if (
filterLevels[index] !== "+" &&
filterLevels[index] !== topicLevels[index]
) {
return false;
}
}
return filterLevels.length === topicLevels.length;
};
// The panel's listen card and its publish button talk to each other through
// the broker, so the mock keeps the subscriptions and delivers to them.
const subscriptions = new Set<{
filter: string;
qos: number;
deliver: (message: MQTTMessage) => void;
}>();
const topicDebug = (topic: string) => ({
topic,
messages: [buildMessage(topic, 0)],
});
// Keyed by device, the way the backend builds this per requested device.
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
"mqtt-fridge-sensor": {
entities: [
{
entity_id: "sensor.fridge_temperature",
discovery_data: {
topic: "homeassistant/sensor/fridge/temperature/config",
payload: {
name: "Temperature",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "°C",
device_class: "temperature",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
{
entity_id: "sensor.fridge_battery",
discovery_data: {
topic: "homeassistant/sensor/fridge/battery/config",
payload: {
name: "Battery",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "%",
device_class: "battery",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
],
triggers: [],
},
"mqtt-garage-door": {
entities: [
{
entity_id: "cover.garage_door",
discovery_data: {
topic: "homeassistant/cover/garage/config",
payload: {
name: "Garage door",
state_topic: "shellyplus1/status/cover:0",
command_topic: "shellyplus1/command/cover:0",
device_class: "garage",
},
},
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
transmitted: [topicDebug("shellyplus1/command/cover:0")],
},
],
triggers: [],
},
};
export const mockMqtt = (hass: MockHomeAssistant) => {
hass.mockWS(
"mqtt/subscribe",
(msg: { topic: string; qos?: number }, _hass, onChange) => {
// Echo a message on the subscribed topic every few seconds so the
// listen card in the MQTT panel shows traffic.
const qos = msg.qos ?? 0;
const topic = resolveFilter(msg.topic);
const deliver = (message: MQTTMessage) => onChange?.(message);
const subscription = { filter: msg.topic, qos, deliver };
subscriptions.add(subscription);
const send = () => deliver(buildMessage(topic, qos));
const stopInitial = emitInitial(send);
const interval = window.setInterval(send, 3000);
return () => {
stopInitial();
clearInterval(interval);
subscriptions.delete(subscription);
};
}
);
// The panel publishes through a script action rather than a `mqtt/` command,
// so without this the publish button only ever reports a failure. Delivering
// to the matching subscriptions is what makes the two halves of the panel
// work together.
hass.mockWS(
"execute_script",
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
msg.sequence
?.filter((action) => action.action === "mqtt.publish")
.forEach((action) => {
const topic = String(action.data?.topic ?? "");
const message: MQTTMessage = {
topic,
payload: String(action.data?.payload ?? ""),
qos: Number(action.data?.qos ?? 0),
retain: action.data?.retain ? 1 : 0,
time: new Date().toISOString(),
};
subscriptions.forEach((subscription) => {
if (filterMatches(subscription.filter, topic)) {
subscription.deliver(message);
}
});
});
return { context: { id: "mock-context" }, response: {} };
}
);
hass.mockWS(
"mqtt/device/debug_info",
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
);
};
@@ -0,0 +1,69 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-rf-bridge";
const DEVICES = [
device(
"rf-bridge-garage",
"Garage bridge",
"Sonoff",
"RF Bridge R2",
ENTRY_ID,
{ area_id: "garden" }
),
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
];
const REGISTRY_ENTRIES = [
registryEntry(
"radio_frequency.garage_bridge",
"rf-bridge-garage",
ENTRY_ID,
"esphome",
"Transceiver"
),
registryEntry(
"radio_frequency.shed_bridge",
"rf-bridge-shed",
ENTRY_ID,
"esphome",
"Transceiver"
),
];
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"radio_frequency.garage_bridge": {
entity_id: "radio_frequency.garage_bridge",
state: minutesAgo(47),
attributes: { friendly_name: "Garage bridge Transceiver" },
},
"radio_frequency.shed_bridge": {
entity_id: "radio_frequency.shed_bridge",
state: "unknown",
attributes: { friendly_name: "Shed bridge Transceiver" },
},
}),
backendTranslations: {
entity_component: {
"component.radio_frequency.entity_component._.name": "Transceiver",
},
},
};
@@ -0,0 +1,25 @@
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
{
entity_id: "radio_frequency.garage_bridge",
device_id: "rf-bridge-garage",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
{
entity_id: "radio_frequency.shed_bridge",
device_id: "rf-bridge-shed",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
];
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
hass.mockWS("radio_frequency/list", () => ({
transmitters: TRANSMITTERS,
}));
};
@@ -0,0 +1,6 @@
import type { ConnectivityFixtures } from "../types";
export const serialFixtures: ConnectivityFixtures = {
components: ["usb"],
commands: ["usb/"],
};
+131
View File
@@ -0,0 +1,131 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
// The RS-485 port below carries the Modbus connections, so both sides name the
// same config entries.
import {
FLEXIT_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "../modbus/fixtures";
const PORTS: SerialPortUsage[] = [
{
device: "/dev/ttyUSB0",
resolved_device:
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
manufacturer: "Nabu Casa",
description: "Home Assistant Connect ZBT-1",
interface_description: "Connect ZBT-1",
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zha"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Home Assistant Connect ZBT-1",
active: true,
domain: "zha",
config_entry_id: "mock-zha",
slug: null,
},
],
discovery_flows: [],
},
{
device: "/dev/ttyACM0",
resolved_device:
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
serial_number: "533D004242",
manufacturer: "Zooz",
description: "800 Series Z-Wave Long Range",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zwave_js"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Z-Wave",
active: true,
domain: "zwave_js",
config_entry_id: "mock-zwave-js",
slug: null,
},
],
discovery_flows: [],
},
{
device: RS485_PORT,
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
manufacturer: "FTDI",
description: "FT232R USB UART",
interface_description: null,
interface_num: 0,
vid: "0403",
pid: "6001",
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [
{
kind: "config_entry",
title: "Flexit Nordic S4",
active: true,
domain: "flexit",
config_entry_id: FLEXIT_ENTRY_ID,
slug: null,
},
{
kind: "config_entry",
title: "SolarEdge SE7K",
active: true,
domain: "solaredge_modbus",
config_entry_id: SOLAREDGE_ENTRY_ID,
slug: null,
},
],
discovery_flows: [],
},
{
// A port that is configured but not currently plugged in. Its add-on icon
// is requested straight from /api/hassio/addons/<slug>/icon, which the
// demo has no backend for, so the icon stays blank here.
device: "/dev/ttyUSB2",
resolved_device: null,
serial_number: "0001",
manufacturer: "Silicon Labs",
description: "CP2102 USB to UART Bridge Controller",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: null,
matching_integrations: [],
present: false,
consumers: [
{
kind: "app",
title: "ESPHome Device Builder",
active: false,
domain: null,
config_entry_id: null,
slug: "esphome",
},
],
discovery_flows: [],
},
];
export const mockSerial = (hass: MockHomeAssistant) => {
hass.mockWS("usb/list_serial_ports", () => PORTS);
hass.mockWS("usb/scan", () => undefined);
};
@@ -0,0 +1,10 @@
// Mocked WebSocket subscriptions are registered synchronously, so a callback
// invoked straight away can land before the subscriber is ready for it: pages
// that ignore messages received before their first render drop it, and
// `createCollection` overwrites it with the empty initial fetch. Emitting the
// first message from a timeout matches the real backend, which always answers
// asynchronously.
export const emitInitial = (send: () => void): (() => void) => {
const timeout = window.setTimeout(send, 0);
return () => clearTimeout(timeout);
};
@@ -0,0 +1,6 @@
import type { ConnectivityFixtures } from "../types";
export const tagsFixtures: ConnectivityFixtures = {
components: ["tag"],
commands: ["tag/"],
};
@@ -0,0 +1,31 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
const THREAD_ENTRY_ID = "mock-thread";
const OTBR_ENTRY_ID = "mock-otbr";
export const threadFixtures: ConnectivityFixtures = {
components: ["thread", "otbr"],
commands: ["thread/", "otbr/"],
manifests: [
manifest("thread", "Thread", {
integration_type: "service",
iot_class: "local_polling",
}),
manifest("otbr", "Open Thread Border Router", {
integration_type: "service",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "service",
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
},
{
type: "service",
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
},
],
};
+441
View File
@@ -0,0 +1,441 @@
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
import type {
ThreadDataSet,
ThreadRouter,
} from "../../../../../src/data/thread";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
const AMAZON_EXT_PAN_ID = "0011223344556677";
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
const ROUTERS: ThreadRouter[] = [
{
instance_name: "HomeAssistant OpenThreadBorderRouter",
addresses: ["192.168.1.10"],
border_agent_id: OTBR_BORDER_AGENT_ID,
brand: "homeassistant",
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
model_name: "OpenThread Border Router",
network_name: "ha-thread",
server: "core-openthread-border-router.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Home Assistant",
},
{
instance_name: "HomePod mini",
addresses: ["192.168.1.24"],
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
brand: "apple",
extended_address: "aabbccddeeff0011",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "HomePod mini",
network_name: "ha-thread",
server: "homepod-mini.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Apple Inc.",
},
{
instance_name: "Nest Hub",
addresses: ["192.168.1.31"],
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
brand: "google",
extended_address: "bbccddeeff001122",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "Google Nest Hub",
network_name: "ha-thread",
server: "nest-hub.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Google Inc.",
},
{
instance_name: "Echo (4th Gen)",
addresses: ["192.168.1.42"],
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
brand: "amazon",
extended_address: "ccddeeff00112233",
extended_pan_id: AMAZON_EXT_PAN_ID,
model_name: "Echo",
network_name: "AmazonThread",
server: "amazon-echo.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Amazon",
},
];
const decodeUtf8 = (value: string): string | undefined => {
const bytes = Uint8Array.from(
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return undefined;
}
};
const tlv = (type: number, value: string) =>
type.toString(16).padStart(2, "0").toUpperCase() +
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
value.toUpperCase();
const textToHex = (text: string) =>
Array.from(text)
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
const replaceTlvField = (value: string, type: number, replacement: string) => {
let index = 0;
let out = "";
let replaced = false;
while (index + 4 <= value.length) {
const fieldType = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const end = index + 4 + length * 2;
out +=
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
replaced = replaced || fieldType === type;
index = end;
}
return replaced ? out : out + tlv(type, replacement);
};
const buildDatasetTlv = (dataset: ThreadDataSet) =>
[
tlv(0x0e, "0000000000010000"),
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
tlv(0x35, "000040010200"),
tlv(0x02, dataset.extended_pan_id),
tlv(0x07, "FD11220000000000"),
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
dataset.network_name === null
? ""
: tlv(0x03, textToHex(dataset.network_name)),
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
tlv(0x0c, "02A0F7F8"),
].join("");
const parseDatasetTlv = (value: string) => {
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
return undefined;
}
const fields = new Map<number, string>();
let index = 0;
while (index < value.length) {
if (index + 4 > value.length) {
return undefined;
}
const type = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const start = index + 4;
const end = start + length * 2;
if (end > value.length) {
return undefined;
}
if (fields.has(type)) {
return undefined;
}
fields.set(type, value.slice(start, end).toUpperCase());
index = end;
}
const extendedPanId = fields.get(0x02);
const networkName = fields.get(0x03);
const activeTimestamp = fields.get(0x0e);
if (
!extendedPanId ||
extendedPanId.length !== 16 ||
!activeTimestamp ||
activeTimestamp.length !== 16
) {
return undefined;
}
const decodedName =
networkName === undefined ? null : decodeUtf8(networkName);
if (decodedName === undefined) {
return undefined;
}
const channel = fields.get(0x00);
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
if (channelNumber === 0) {
return undefined;
}
return {
activeTimestamp,
extendedPanId,
networkName: decodedName,
panId: fields.get(0x01) ?? null,
channel: channelNumber,
};
};
const DATASETS: ThreadDataSet[] = [
{
channel: 15,
created: new Date(Date.now() - 86400000 * 30).toISOString(),
dataset_id: "ha-thread-dataset",
extended_pan_id: HA_EXT_PAN_ID,
network_name: "ha-thread",
pan_id: "1234",
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
preferred_extended_address: OTBR_EXT_ADDRESS,
preferred: true,
source: "otbr",
},
];
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
);
const OTBR_INFO: OTBRInfoDict = {
[OTBR_EXT_ADDRESS]: {
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
border_agent_id: OTBR_BORDER_AGENT_ID,
channel: 15,
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
url: "http://core-openthread-border-router:8081",
},
};
let added = 0;
let created = 0;
const randomExtendedPanId = () =>
Array.from({ length: 8 }, () =>
Math.floor(Math.random() * 256)
.toString(16)
.padStart(2, "0")
)
.join("")
.toUpperCase();
const moveRouter = (
extendedAddress: string,
extendedPanId: string,
networkName: string | null,
datasetId: string
) => {
const info = OTBR_INFO[extendedAddress];
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
if (info) {
info.extended_pan_id = extendedPanId;
info.active_dataset_tlvs =
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
info.channel = moved?.channel ?? info.channel;
}
const router = ROUTERS.find(
(candidate) => candidate.extended_address === extendedAddress
);
if (router) {
router.extended_pan_id = extendedPanId;
router.network_name = networkName;
announce(router);
}
};
type RouterListener = (event: {
key: string;
type: "router_discovered" | "router_removed";
data: ThreadRouter;
}) => void;
const listeners = new Set<RouterListener>();
const announce = (router: ThreadRouter) =>
listeners.forEach((listener) =>
listener({
key: router.extended_address,
type: "router_discovered",
data: router,
})
);
export const mockThread = (hass: MockHomeAssistant) => {
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
const listener = onChange as RouterListener | undefined;
if (listener) {
listeners.add(listener);
}
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
return () => {
stopInitial();
if (listener) {
listeners.delete(listener);
}
};
});
hass.mockWS("thread/list_datasets", () => ({
datasets: DATASETS.map((dataset) => ({ ...dataset })),
}));
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
const value = DATASET_TLVS[msg.dataset_id];
if (!value) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
return { tlv: value };
});
hass.mockWS("otbr/info", () => OTBR_INFO);
hass.mockWS(
"thread/add_dataset_tlv",
(msg: { source: string; tlv: string }) => {
const parsed = parseDatasetTlv(msg.tlv);
if (!parsed) {
throw new Error("Invalid dataset");
}
const existing = DATASETS.find(
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
);
if (existing) {
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
return undefined;
}
existing.channel = parsed.channel;
existing.network_name = parsed.networkName;
existing.pan_id = parsed.panId;
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
added += 1;
const dataset: ThreadDataSet = {
channel: parsed.channel,
created: new Date().toISOString(),
dataset_id: `added-dataset-${added}`,
extended_pan_id: parsed.extendedPanId,
network_name: parsed.networkName,
pan_id: parsed.panId,
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: msg.source,
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
);
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
const index = DATASETS.findIndex(
(dataset) => dataset.dataset_id === msg.dataset_id
);
if (index === -1) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
if (DATASETS[index].preferred) {
throw new Error("Preferred dataset cannot be deleted");
}
DATASETS.splice(index, 1);
delete DATASET_TLVS[msg.dataset_id];
return undefined;
});
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
DATASETS.forEach((dataset) => {
dataset.preferred = dataset.dataset_id === msg.dataset_id;
});
return undefined;
});
hass.mockWS(
"thread/set_preferred_border_agent",
(msg: {
dataset_id: string;
border_agent_id: string | null;
extended_address: string;
}) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
dataset.preferred_border_agent_id = msg.border_agent_id;
dataset.preferred_extended_address = msg.extended_address;
}
return undefined;
}
);
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
created += 1;
const dataset: ThreadDataSet = {
channel: 15,
created: new Date().toISOString(),
dataset_id: `created-dataset-${created}`,
extended_pan_id: randomExtendedPanId(),
network_name: `ha-thread-${created}`,
pan_id: "1234",
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: "otbr",
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
return undefined;
});
hass.mockWS(
"otbr/set_network",
(msg: { extended_address: string; dataset_id: string }) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = dataset.channel ?? info.channel;
}
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
}
return undefined;
}
);
hass.mockWS(
"otbr/set_channel",
(msg: { extended_address: string; channel: number }) => {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = msg.channel;
const dataset = DATASETS.find(
(candidate) => candidate.extended_pan_id === info.extended_pan_id
);
if (dataset) {
dataset.channel = msg.channel;
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
DATASET_TLVS[dataset.dataset_id],
0x00,
`00${msg.channel.toString(16).padStart(4, "0")}`
);
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
}
}
return { delay: 120 };
}
);
};
+38
View File
@@ -0,0 +1,38 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type {
IntegrationManifest,
IntegrationType,
} from "../../../../src/data/integration";
import type { TranslationCategory } from "../../../../src/data/translation";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
export interface DemoConfigEntry {
entry: ConfigEntry;
type: IntegrationType;
}
/**
* Everything one connectivity integration contributes to the demo besides its
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
* must not pull in the mocks (which are code-split into the config panel
* chunk). Each integration owns one of these, so they stay independent.
*/
export interface ConnectivityFixtures {
/** Components to load, so the integration's panel is reachable. */
components: string[];
/** WS command prefixes served by the integration's mock, if it has one. */
commands?: string[];
configEntries?: DemoConfigEntry[];
/** Manifests for the domains above, so their integration pages open. */
manifests?: IntegrationManifest[];
devices?: DeviceRegistryEntry[];
entityRegistryEntries?: EntityRegistryEntry[];
/** States for the entities above; built lazily so timestamps stay fresh. */
entities?: () => EntityInput[];
/** Backend translations the panel looks up, by category. */
backendTranslations?: Partial<
Record<TranslationCategory, Record<string, string>>
>;
}
+232
View File
@@ -0,0 +1,232 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zha";
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
const DEVICES = [
device(
"zha-coordinator",
"Home Assistant Connect ZBT-1",
"Nabu Casa",
"Connect ZBT-1",
ENTRY_ID,
{
sw_version: "7.4.4.0",
connections: [["zigbee", COORDINATOR_IEEE]],
identifiers: [["zha", COORDINATOR_IEEE]],
}
),
device(
"zha-porch-light",
"Porch light",
"IKEA of Sweden",
"TRADFRI bulb E27 CWS 806lm",
ENTRY_ID,
{
connections: [["zigbee", PORCH_IEEE]],
identifiers: [["zha", PORCH_IEEE]],
}
),
device(
"zha-hall-motion",
"Hall motion",
"IKEA of Sweden",
"TRADFRI motion sensor",
ENTRY_ID,
{
connections: [["zigbee", MOTION_IEEE]],
identifiers: [["zha", MOTION_IEEE]],
}
),
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
area_id: "living_room",
connections: [["zigbee", PLUG_IEEE]],
identifiers: [["zha", PLUG_IEEE]],
}),
device(
"zha-kitchen-switch",
"Kitchen switch",
"IKEA of Sweden",
"TRADFRI on/off switch",
ENTRY_ID,
{
area_id: "kitchen",
connections: [["zigbee", KITCHEN_IEEE]],
identifiers: [["zha", KITCHEN_IEEE]],
}
),
device(
"zha-landing-sensor",
"Landing sensor",
"Aqara",
"WSDCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", LANDING_IEEE]],
identifiers: [["zha", LANDING_IEEE]],
}
),
device(
"zha-garage-contact",
"Garage contact",
"Aqara",
"MCCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", GARAGE_IEEE]],
identifiers: [["zha", GARAGE_IEEE]],
}
),
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
area_id: "office",
connections: [["zigbee", OFFICE_IEEE]],
identifiers: [["zha", OFFICE_IEEE]],
}),
];
const REGISTRY_ENTRIES = [
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
registryEntry(
"binary_sensor.hall_motion",
"zha-hall-motion",
ENTRY_ID,
"zha"
),
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
registryEntry(
"sensor.kitchen_switch_battery",
"zha-kitchen-switch",
ENTRY_ID,
"zha"
),
registryEntry(
"sensor.landing_temperature",
"zha-landing-sensor",
ENTRY_ID,
"zha"
),
registryEntry(
"binary_sensor.garage_contact",
"zha-garage-contact",
ENTRY_ID,
"zha"
),
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
];
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
DEVICES.flatMap((entry) => {
const areaId = entry.area_id;
return areaId
? entry.connections
.filter(([type]) => type === "zigbee")
.map(([, ieee]) => [ieee, areaId])
: [];
})
);
export const zhaFixtures: ConnectivityFixtures = {
components: ["zha"],
commands: ["zha/"],
manifests: [
manifest("zha", "Zigbee Home Automation", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.porch": {
entity_id: "light.porch",
state: "off",
attributes: {
friendly_name: "Porch light",
supported_color_modes: ["hs"],
},
},
"binary_sensor.hall_motion": {
entity_id: "binary_sensor.hall_motion",
state: "off",
attributes: { friendly_name: "Hall motion", device_class: "motion" },
},
"switch.tv_plug": {
entity_id: "switch.tv_plug",
state: "unavailable",
attributes: { friendly_name: "TV plug" },
},
"sensor.kitchen_switch_battery": {
entity_id: "sensor.kitchen_switch_battery",
state: "78",
attributes: {
friendly_name: "Kitchen switch battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.landing_temperature": {
entity_id: "sensor.landing_temperature",
state: "19.6",
attributes: {
friendly_name: "Landing temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"binary_sensor.garage_contact": {
entity_id: "binary_sensor.garage_contact",
state: "off",
attributes: { friendly_name: "Garage contact", device_class: "door" },
},
"switch.office_desk": {
entity_id: "switch.office_desk",
state: "on",
attributes: { friendly_name: "Office desk" },
},
}),
backendTranslations: {
config_panel: {
"component.zha.config_panel.zha_options.title": "Global options",
"component.zha.config_panel.zha_options.default_light_transition":
"Default light transition time (seconds)",
"component.zha.config_panel.zha_options.enhanced_light_transition":
"Enable enhanced light color/temperature transition from an off state",
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
"Always prefer XY color mode",
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
"Alarm master code",
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
"Failed authentication attempts before restart",
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
"Code required for arming",
},
},
};
+793
View File
@@ -0,0 +1,793 @@
import type {
Attribute,
AttributeConfigurationStatus,
Cluster,
ClusterConfigurationEvent,
Command,
Neighbor,
ReadAttributeServiceData,
ZHAConfiguration,
ZHADevice,
ZHADeviceEndpoint,
ZHAGroup,
ZHAEntityReference,
ZHAGroupMember,
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import {
AREA_BY_IEEE,
LANDING_IEEE,
COORDINATOR_IEEE,
GARAGE_IEEE,
KITCHEN_IEEE,
MOTION_IEEE,
OFFICE_IEEE,
PLUG_IEEE,
PORCH_IEEE,
} from "./fixtures";
const neighbor = (
ieee: string,
nwk: string,
lqi: string,
relationship: string,
depth = "1"
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
const DEVICES: ZHADevice[] = [
{
available: true,
name: "Nabu Casa Connect ZBT-1",
ieee: COORDINATOR_IEEE,
nwk: 0x0000,
lqi: 255,
rssi: "0",
last_seen: minutesAgo(0),
manufacturer: "Nabu Casa",
model: "Connect ZBT-1",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: "Coordinator",
active_coordinator: true,
signature: {},
neighbors: [
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
],
routes: [],
},
{
available: true,
name: "TRADFRI bulb E27 CWS 806lm",
ieee: PORCH_IEEE,
nwk: 0x1a2b,
lqi: 224,
rssi: "-58",
last_seen: minutesAgo(2),
manufacturer: "IKEA of Sweden",
model: "TRADFRI bulb E27 CWS 806lm",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
],
routes: [],
},
{
available: true,
name: "TRADFRI motion sensor",
ieee: MOTION_IEEE,
nwk: 0x5e6f,
lqi: 142,
rssi: "-77",
last_seen: minutesAgo(9),
manufacturer: "IKEA of Sweden",
model: "TRADFRI motion sensor",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
routes: [],
},
{
available: false,
name: "Innr SP 220",
ieee: PLUG_IEEE,
nwk: 0x3c4d,
lqi: 198,
rssi: "-64",
last_seen: minutesAgo(240),
manufacturer: "Innr",
model: "SP 220",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
routes: [],
},
{
available: true,
name: "TRADFRI on/off switch",
ieee: KITCHEN_IEEE,
nwk: 0x9c0d,
lqi: 186,
rssi: "-69",
last_seen: minutesAgo(4),
manufacturer: "IKEA of Sweden",
model: "TRADFRI on/off switch",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
routes: [],
},
{
available: true,
name: "Aqara temperature sensor",
ieee: LANDING_IEEE,
nwk: 0xab12,
lqi: 164,
rssi: "-74",
last_seen: minutesAgo(6),
manufacturer: "Aqara",
model: "WSDCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Aqara door sensor",
ieee: GARAGE_IEEE,
nwk: 0xcd34,
lqi: 118,
rssi: "-83",
last_seen: minutesAgo(21),
manufacturer: "Aqara",
model: "MCCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Innr SP 240",
ieee: OFFICE_IEEE,
nwk: 0x7a8b,
lqi: 211,
rssi: "-61",
last_seen: minutesAgo(1),
manufacturer: "Innr",
model: "SP 240",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
],
routes: [],
},
];
DEVICES.forEach((zhaDevice) => {
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
});
const deviceByIeee = (ieee: string): ZHADevice =>
DEVICES.find((d) => d.ieee === ieee)!;
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
{
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
};
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
device: deviceByIeee(ieee),
endpoint_id: endpointId,
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
(entity) =>
({
...entity,
original_name: entity.name,
}) as ZHAEntityReference
),
});
const GROUPS: ZHAGroup[] = [
{
name: "Downstairs lights",
group_id: 1,
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
},
{
name: "Outdoor lights",
group_id: 2,
members: [member(PORCH_IEEE)],
},
];
const CONFIGURATION: ZHAConfiguration = {
data: {
zha_options: {
default_light_transition: 0,
enhanced_light_transition: false,
light_transitioning_flag: true,
always_prefer_xy_color_mode: true,
group_members_assume_state: true,
consider_unavailable_mains: 7200,
consider_unavailable_battery: 21600,
},
zha_alarm_options: {
alarm_master_code: "1234",
alarm_failed_tries: 3,
alarm_arm_requires_code: false,
},
},
schemas: {
zha_options: [
{
name: "default_light_transition",
required: true,
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
},
{
name: "enhanced_light_transition",
required: true,
selector: { boolean: {} },
},
{
name: "always_prefer_xy_color_mode",
required: true,
selector: { boolean: {} },
},
],
zha_alarm_options: [
{ name: "alarm_master_code", required: true, selector: { text: {} } },
{
name: "alarm_failed_tries",
required: true,
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
},
{
name: "alarm_arm_requires_code",
required: true,
selector: { boolean: {} },
},
],
},
};
const BACKUPS: ZHANetworkBackup[] = [];
const NETWORK_SETTINGS: ZHANetworkSettings = {
radio_type: "ezsp",
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
settings: {
backup_time: new Date(Date.now() - 3600000).toISOString(),
node_info: {
nwk: "0x0000",
ieee: COORDINATOR_IEEE,
logical_type: "coordinator",
},
network_info: {
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
pan_id: "0x1234",
nwk_update_id: 0,
nwk_manager_id: "0x0000",
channel: 15,
channel_mask: [15, 20, 25],
security_level: 5,
network_key: {
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
tx_counter: 138234,
rx_counter: 0,
seq: 0,
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
},
tc_link_key: {
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
tx_counter: 0,
rx_counter: 0,
seq: 0,
partner_ieee: COORDINATOR_IEEE,
},
key_table: [],
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
nwk_addresses: {
[PORCH_IEEE]: "0x1a2b",
[MOTION_IEEE]: "0x5e6f",
[PLUG_IEEE]: "0x3c4d",
[KITCHEN_IEEE]: "0x9c0d",
[LANDING_IEEE]: "0xab12",
[GARAGE_IEEE]: "0xcd34",
[OFFICE_IEEE]: "0x7a8b",
},
stack_specific: {},
metadata: { ezsp: { stack_version: "7.4.4.0" } },
source: "[email protected]",
},
},
};
interface ClusterDefinition {
name: string;
attributes: Attribute[];
commands: Command[];
}
const numberField = (name: string, max: number) => ({
name,
required: true,
selector: { number: { min: 0, max, mode: "box" as const } },
});
const CLUSTERS: Record<number, ClusterDefinition> = {
0: {
name: "Basic",
attributes: [
{ name: "zcl_version", id: 0 },
{ name: "app_version", id: 1 },
{ name: "manufacturer", id: 4 },
{ name: "model", id: 5 },
],
commands: [],
},
1: {
name: "PowerConfiguration",
attributes: [
{ name: "battery_voltage", id: 32 },
{ name: "battery_percentage_remaining", id: 33 },
],
commands: [],
},
3: {
name: "Identify",
attributes: [{ name: "identify_time", id: 0 }],
commands: [
{
name: "identify",
id: 0,
type: "server",
schema: [numberField("identify_time", 65535)],
},
],
},
4: {
name: "Groups",
attributes: [{ name: "name_support", id: 0 }],
commands: [],
},
6: {
name: "OnOff",
attributes: [{ name: "on_off", id: 0 }],
commands: [
{ name: "off", id: 0, type: "server", schema: [] },
{ name: "on", id: 1, type: "server", schema: [] },
{ name: "toggle", id: 2, type: "server", schema: [] },
],
},
8: {
name: "LevelControl",
attributes: [{ name: "current_level", id: 0 }],
commands: [
{
name: "move_to_level",
id: 0,
type: "server",
schema: [
numberField("level", 254),
numberField("transition_time", 65535),
],
},
],
},
25: {
name: "Ota",
attributes: [{ name: "current_file_version", id: 2 }],
commands: [],
},
768: {
name: "ColorControl",
attributes: [
{ name: "current_hue", id: 0 },
{ name: "current_saturation", id: 1 },
{ name: "color_temperature", id: 7 },
],
commands: [
{
name: "move_to_color_temp",
id: 10,
type: "server",
schema: [
numberField("color_temp_mireds", 500),
numberField("transition_time", 65535),
],
},
],
},
1026: {
name: "TemperatureMeasurement",
attributes: [
{ name: "measured_value", id: 0 },
{ name: "min_measured_value", id: 1 },
{ name: "max_measured_value", id: 2 },
],
commands: [],
},
1280: {
name: "IasZone",
attributes: [
{ name: "zone_state", id: 0 },
{ name: "zone_type", id: 1 },
{ name: "zone_status", id: 2 },
],
commands: [],
},
2820: {
name: "ElectricalMeasurement",
attributes: [
{ name: "rms_voltage", id: 1285 },
{ name: "rms_current", id: 1288 },
{ name: "active_power", id: 1291 },
],
commands: [],
},
};
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
[
...inIds.map((id) => ({ id, type: "in" })),
...outIds.map((id) => ({ id, type: "out" })),
].map(({ id, type }) => ({
name: CLUSTERS[id].name,
id,
endpoint_id: 1,
type,
}));
// A device binds from its client side, and group binding lists only those, so
// the remotes carry the `out` clusters they would have on real hardware and
// the mains-powered devices only their OTA one.
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
[COORDINATOR_IEEE]: clusterList([0]),
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
};
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
"1:32": "30",
"1:33": "184",
"3:0": "0",
"4:0": "0",
"6:0": "1",
"8:0": "254",
"768:0": "42",
"768:1": "180",
"768:7": "370",
"1026:0": "2140",
"1026:1": "-2000",
"1026:2": "6000",
"1280:0": "1",
"1280:1": "21",
"1280:2": "0",
"2820:1285": "2300",
"2820:1288": "410",
"2820:1291": "94",
};
const writtenAttributes = new Map<string, string>();
const attributeKey = (data: ReadAttributeServiceData) =>
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
const attributeValue = (data: ReadAttributeServiceData): string => {
const written = writtenAttributes.get(attributeKey(data));
if (written !== undefined) {
return written;
}
if (data.cluster_id === 0) {
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
if (data.attribute === 4) {
return device?.manufacturer ?? "";
}
if (data.attribute === 5) {
return device?.model ?? "";
}
return "3";
}
return (
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
);
};
export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices", () => DEVICES);
hass.mockWS("zha/device", (msg: { ieee: string }) =>
DEVICES.find((device) => device.ieee === msg.ieee)
);
hass.mockWS("zha/groups", () => GROUPS);
hass.mockWS("zha/group", (msg: { group_id: number }) =>
GROUPS.find((group) => group.group_id === msg.group_id)
);
// Copied: both options editors mutate the fetched data as the user changes a
// control, so handing out the backing object would persist edits that were
// never saved. Only the update below writes to it.
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
hass.mockWS("zha/topology/update", () => undefined);
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
hass.mockWS("zha/devices/unbind", () => undefined);
hass.mockWS("zha/groups/bind", () => undefined);
hass.mockWS("zha/groups/unbind", () => undefined);
hass.mockWS(
"zha/devices/clusters",
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
);
hass.mockWS(
"zha/devices/clusters/commands",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes/value",
(msg: ReadAttributeServiceData) => attributeValue(msg)
);
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
const write = data as ReadAttributeServiceData & { value: unknown };
writtenAttributes.set(attributeKey(write), String(write.value));
return undefined;
});
hass.mockWS("zha/devices/groupable", () => [
member(PORCH_IEEE),
member(OFFICE_IEEE),
member(PLUG_IEEE),
]);
hass.mockWS("zha/network/backups/list", () => BACKUPS);
hass.mockWS("zha/devices/permit", () => () => undefined);
hass.mockWS(
"zha/devices/reconfigure",
(msg: { ieee: string }, _hass, onChange) => {
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
const timers: number[] = [];
let cancelled = false;
const emit = (event: ClusterConfigurationEvent, step: number) => {
timers.push(
window.setTimeout(() => {
if (!cancelled) {
onChange!(event);
}
}, step * 400)
);
};
deviceClusters.forEach((cluster, index) => {
emit(
{
type: "zha_channel_bind",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
success: true,
},
},
index + 1
);
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
cluster.id
].attributes.map((attribute) => ({
...attribute,
status: "SUCCESS",
min: 30,
max: 900,
change: 1,
}));
if (attributes.length) {
emit(
{
type: "zha_channel_configure_reporting",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
attributes,
},
},
index + 1
);
}
});
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
return () => {
cancelled = true;
timers.forEach((timer) => clearTimeout(timer));
};
}
);
hass.mockWS(
"zha/configuration/update",
(msg: { data: ZHAConfiguration["data"] }) => {
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
CONFIGURATION.data[section] = {
...CONFIGURATION.data[section],
...values,
};
});
return undefined;
}
);
hass.mockWS("zha/network/backups/create", () => {
const backup: ZHANetworkBackup = {
backup_time: new Date().toISOString(),
// Copied, or changing the channel afterwards would rewrite the backup
// too, which is the one thing a backup must not do.
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
};
BACKUPS.push(backup);
return { backup, is_complete: true };
});
hass.mockWS(
"zha/network/change_channel",
(msg: { new_channel: "auto" | number }) => {
NETWORK_SETTINGS.settings.network_info.channel =
msg.new_channel === "auto" ? 25 : msg.new_channel;
return undefined;
}
);
hass.mockWS(
"zha/group/add",
(msg: {
group_name: string;
group_id?: number;
members?: ZHAGroupMember[];
}) => {
const group: ZHAGroup = {
name: msg.group_name,
group_id:
msg.group_id ??
GROUPS.reduce(
(highest, item) => Math.max(highest, item.group_id),
0
) + 1,
members: (msg.members ?? []).map((item) => member(item.ieee)),
};
GROUPS.push(group);
return group;
}
);
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
msg.group_ids.forEach((groupId) => {
const index = GROUPS.findIndex((group) => group.group_id === groupId);
if (index !== -1) {
GROUPS.splice(index, 1);
}
});
return GROUPS;
});
const findGroup = (groupId: number) => {
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
if (!group) {
throw new Error(`Group ${groupId} not found`);
}
return group;
};
hass.mockWS(
"zha/group/members/add",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const known = new Set(group.members.map((item) => item.device.ieee));
group.members = [
...group.members,
...msg.members
.filter((item) => !known.has(item.ieee))
.map((item) => member(item.ieee)),
];
return group;
}
);
hass.mockWS(
"zha/group/members/remove",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const dropped = new Set(msg.members.map((item) => item.ieee));
group.members = group.members.filter(
(item) => !dropped.has(item.device.ieee)
);
return group;
}
);
};
@@ -0,0 +1,201 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zwave-js";
export const HOME_ID = 3245146787;
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
// panels read them back from.
export const CONTROLLER_NODE_ID = 1;
export const HALLWAY_NODE_ID = 7;
export const DIMMER_NODE_ID = 12;
export const MOTION_NODE_ID = 15;
export const LOCK_NODE_ID = 18;
export const THERMOSTAT_NODE_ID = 20;
export const SENSOR_NODE_ID = 23;
export const OUTLET_NODE_ID = 31;
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
[CONTROLLER_NODE_ID]: "zwave-controller",
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
[MOTION_NODE_ID]: "zwave-garage-motion",
[LOCK_NODE_ID]: "zwave-back-door-lock",
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
[SENSOR_NODE_ID]: "zwave-basement-sensor",
[OUTLET_NODE_ID]: "zwave-porch-outlet",
};
const identifiers = (nodeId: number): [string, string][] => [
["zwave_js", `${HOME_ID}-${nodeId}`],
];
const DEVICES = [
device(
"zwave-controller",
"Z-Wave stick",
"Zooz",
"800 Series Z-Wave Long Range",
ENTRY_ID,
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
),
device(
"zwave-dining-dimmer",
"Dining room dimmer",
"Inovelli",
"LZW31-SN",
ENTRY_ID,
{ identifiers: identifiers(DIMMER_NODE_ID) }
),
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
identifiers: identifiers(LOCK_NODE_ID),
}),
device(
"zwave-basement-sensor",
"Basement sensor",
"Aeotec",
"ZWA005 TriSensor",
ENTRY_ID,
{ identifiers: identifiers(SENSOR_NODE_ID) }
),
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
area_id: "entrance",
identifiers: identifiers(HALLWAY_NODE_ID),
}),
device(
"zwave-garage-motion",
"Garage motion",
"Aeotec",
"MultiSensor 7",
ENTRY_ID,
{ identifiers: identifiers(MOTION_NODE_ID) }
),
device(
"zwave-bedroom-thermostat",
"Bedroom thermostat",
"Honeywell",
"T6 Pro",
ENTRY_ID,
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
),
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
identifiers: identifiers(OUTLET_NODE_ID),
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.dining_room",
"zwave-dining-dimmer",
ENTRY_ID,
"zwave_js"
),
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
registryEntry(
"sensor.basement_humidity",
"zwave-basement-sensor",
ENTRY_ID,
"zwave_js"
),
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
registryEntry(
"binary_sensor.garage_motion",
"zwave-garage-motion",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"climate.bedroom",
"zwave-bedroom-thermostat",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"switch.porch_outlet",
"zwave-porch-outlet",
ENTRY_ID,
"zwave_js"
),
];
export const zwaveJsFixtures: ConnectivityFixtures = {
components: ["zwave_js"],
commands: ["zwave_js/"],
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.dining_room": {
entity_id: "light.dining_room",
state: "on",
attributes: {
friendly_name: "Dining room dimmer",
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 128,
},
},
"lock.back_door": {
entity_id: "lock.back_door",
state: "unlocked",
attributes: { friendly_name: "Back door lock" },
},
"sensor.basement_humidity": {
entity_id: "sensor.basement_humidity",
state: "58",
attributes: {
friendly_name: "Basement humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"light.hallway": {
entity_id: "light.hallway",
state: "off",
attributes: {
friendly_name: "Hallway switch",
supported_color_modes: ["onoff"],
},
},
"binary_sensor.garage_motion": {
entity_id: "binary_sensor.garage_motion",
state: "on",
attributes: { friendly_name: "Garage motion", device_class: "motion" },
},
"climate.bedroom": {
entity_id: "climate.bedroom",
state: "heat",
attributes: {
friendly_name: "Bedroom thermostat",
hvac_modes: ["off", "heat"],
current_temperature: 19.6,
temperature: 20.5,
min_temp: 7,
max_temp: 30,
supported_features: 1,
},
},
"switch.porch_outlet": {
entity_id: "switch.porch_outlet",
state: "off",
attributes: { friendly_name: "Porch outlet" },
},
}),
};
@@ -0,0 +1,229 @@
import type {
ZWaveJSController,
ZWaveJSNetwork,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
ZwaveJSProvisioningEntry,
} from "../../../../../src/data/zwave_js";
import {
NodeStatus,
ProvisioningEntryStatus,
SecurityClass,
} from "../../../../../src/data/zwave_js";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import {
CONTROLLER_NODE_ID,
DEVICE_IDS_BY_NODE_ID,
DIMMER_NODE_ID,
HALLWAY_NODE_ID,
HOME_ID,
LOCK_NODE_ID,
MOTION_NODE_ID,
OUTLET_NODE_ID,
SENSOR_NODE_ID,
THERMOSTAT_NODE_ID,
} from "./fixtures";
const node = (
nodeId: number,
status: NodeStatus,
overrides: Partial<ZWaveJSNodeStatus> = {}
): ZWaveJSNodeStatus => ({
node_id: nodeId,
ready: true,
status,
is_secure: true,
is_routing: true,
zwave_plus_version: 2,
highest_security_class: SecurityClass.S2_Authenticated,
is_controller_node: false,
has_firmware_update_cc: true,
...overrides,
});
const NODES: ZWaveJSNodeStatus[] = [
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
is_controller_node: true,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(HALLWAY_NODE_ID, NodeStatus.Alive),
node(DIMMER_NODE_ID, NodeStatus.Alive),
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
node(LOCK_NODE_ID, NodeStatus.Asleep, {
is_routing: false,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
node(OUTLET_NODE_ID, NodeStatus.Alive),
];
const CONTROLLER: ZWaveJSController = {
home_id: HOME_ID,
sdk_version: "7.19.3",
type: 1,
own_node_id: CONTROLLER_NODE_ID,
rf_region: null,
is_primary: true,
is_using_home_id_from_other_network: false,
is_sis_present: true,
was_real_primary: true,
is_suc: true,
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
node_type: 0 as ZWaveJSController["node_type"],
firmware_version: "1.10",
manufacturer_id: 634,
product_id: 4,
product_type: 3,
supported_function_types: [],
suc_node_id: CONTROLLER_NODE_ID,
supports_timers: false,
is_rebuilding_routes: false,
// InclusionState.Idle
inclusion_state: 0,
nodes: NODES,
supports_long_range: true,
};
const NETWORK: ZWaveJSNetwork = {
client: {
state: "connected",
ws_server_url: "ws://localhost:3000",
server_version: "1.40.1",
driver_version: "13.2.0",
},
controller: CONTROLLER,
};
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
{
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
securityClasses: [SecurityClass.S2_Authenticated],
status: ProvisioningEntryStatus.Active,
additionalProperties: {},
manufacturer: "Zooz",
label: "ZEN32 Scene Controller",
},
];
// Node IDs each node can reach directly. Only requested when the map's
// neighbor overlay is toggled on.
const NEIGHBORS: Record<number, number[]> = {
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
[HALLWAY_NODE_ID]: [
CONTROLLER_NODE_ID,
DIMMER_NODE_ID,
LOCK_NODE_ID,
THERMOSTAT_NODE_ID,
],
[DIMMER_NODE_ID]: [
CONTROLLER_NODE_ID,
HALLWAY_NODE_ID,
SENSOR_NODE_ID,
OUTLET_NODE_ID,
],
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
};
// Route each node reports as its last working route back to the controller,
// so the map can draw the mesh instead of a star.
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
[MOTION_NODE_ID]: {
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
rssi: -79,
},
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
};
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
deviceId,
Number(nodeId),
])
);
const buildNodeStatistics = (
nodeId: number
): ZWaveJSNodeStatisticsUpdatedMessage => {
const route = ROUTES[nodeId];
return {
event: "statistics updated",
source: "node",
nodeId,
node_id: nodeId,
commands_tx: 1200 + nodeId * 7,
commands_rx: 980 + nodeId * 5,
commands_dropped_tx: 0,
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
timeout_response: 0,
rtt: 24 + nodeId,
rssi: route?.rssi ?? null,
lwr: route
? {
protocol_data_rate: 3,
repeaters: route.repeaters.map(
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
),
rssi: route.rssi,
repeater_rssi: route.repeaters.map(() => -55),
route_failed_between: null,
}
: null,
nlwr: null,
};
};
export const mockZwaveJs = (hass: MockHomeAssistant) => {
hass.mockWS("zwave_js/network_status", () => NETWORK);
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
hass.mockWS("zwave_js/data_collection_status", () => ({
enabled: false,
opted_in: false,
}));
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
});
hass.mockWS(
"zwave_js/subscribe_node_statistics",
(msg: { device_id: string }, _hass, onChange) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
return () => undefined;
}
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
}
);
hass.mockWS(
"zwave_js/subscribe_controller_statistics",
(_msg, _hass, onChange) =>
emitInitial(() =>
onChange?.({
event: "statistics updated",
source: "controller",
messages_tx: 18234,
messages_rx: 17980,
messages_dropped_tx: 2,
messages_dropped_rx: 5,
nak: 0,
can: 3,
timeout_ack: 1,
timeout_response: 0,
timeout_callback: 0,
})
)
);
};
+37
View File
@@ -1,4 +1,5 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
@@ -51,4 +52,40 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
...connectivityDevices,
];
+5
View File
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
+11 -16
View File
@@ -1,19 +1,7 @@
import type { IntegrationManifest } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
import { connectivityManifests } from "./connectivity/fixtures";
import { manifest } from "./manifest";
const manifests: IntegrationManifest[] = [
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
@@ -62,11 +50,18 @@ const manifests: IntegrationManifest[] = [
integration_type: "helper",
iot_class: "local_polling",
}),
...connectivityManifests,
];
export const mockIntegration = (hass: MockHomeAssistant) => {
hass.mockWS("manifest/list", () => manifests);
hass.mockWS("manifest/get", (msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration)
// Never answer with undefined: the integration page reads the manifest it
// gets back without guarding, so an unlisted domain would throw. The real
// backend always has a manifest for a domain that has config entries.
hass.mockWS(
"manifest/get",
(msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration) ??
manifest(msg.integration, msg.integration)
);
};
+20
View File
@@ -0,0 +1,20 @@
import type { IntegrationManifest } from "../../../src/data/integration";
/**
* Builds a demo integration manifest. Lives in its own module so both the
* manifest registry and the per-integration fixtures that feed it can use it
* without importing each other.
*/
export const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
+6
View File
@@ -1,6 +1,12 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
+54 -2
View File
@@ -1,6 +1,58 @@
import type { Tag } from "../../../src/data/tag";
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTags = (hass: MockHomeAssistant) => {
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
let created = 0;
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
hass.mockWS(
"tag/create",
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
if (msg.tag_id && find(msg.tag_id)) {
throw new Error(`Tag ${msg.tag_id} already exists`);
}
let id = msg.tag_id;
while (!id) {
created += 1;
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
}
const tag: Tag = {
id,
name: msg.name,
description: msg.description,
};
tags.push(tag);
return { ...tag };
}
);
hass.mockWS(
"tag/update",
(msg: UpdateTagParams & { tag_id: string }): Tag => {
const tag = find(msg.tag_id);
if (!tag) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
if ("name" in msg) {
tag.name = msg.name;
}
if ("description" in msg) {
tag.description = msg.description;
}
return { ...tag };
}
);
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
if (index === -1) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
tags.splice(index, 1);
return undefined;
});
};
+23 -4
View File
@@ -1,7 +1,26 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityBackendTranslations } from "./connectivity/fixtures";
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
export const mockTranslations = (
hass: MockHomeAssistant,
localizePromise?: Promise<LocalizeFunc>
) => {
hass.mockWS(
"frontend/get_translations",
(msg: { language: string; category?: string }) => ({
resources:
(msg.category && connectivityBackendTranslations[msg.category]) || {},
})
);
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
// that are only requested through it never reach the WebSocket mock above.
// Seed every category into the resources, after the fragment translations so
// this merges on top of them.
(localizePromise ?? Promise.resolve()).then(() =>
hass.addTranslations(
Object.assign({}, ...Object.values(connectivityBackendTranslations))
)
);
};
+1 -3
View File
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
@@ -25,6 +25,9 @@ title: Button
<ha-button appearance="filled">
filled button
</ha-button>
<ha-button appearance="outlined">
outlined button
</ha-button>
<ha-button size="s">
small
@@ -65,7 +68,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| Name | Type | Default | Description |
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
+1 -1
View File
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const appearances = ["accent", "filled", "plain"];
const appearances = ["accent", "filled", "outlined", "plain"];
const variants = ["brand", "danger", "neutral", "warning", "success"];
@customElement("demo-components-ha-button")
+125
View File
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
interface TreeChild {
key: string;
label: string;
}
interface TreeGroup {
key: string;
label: string;
children: TreeChild[];
}
const treeGroups: TreeGroup[] = [
{
key: "binary_sensor",
label: "Binary sensor",
children: [
{ key: "door", label: "Door" },
{ key: "motion", label: "Motion" },
{ key: "window", label: "Window" },
],
},
{
key: "cover",
label: "Cover",
children: [
{ key: "garage", label: "Garage" },
{ key: "shutter", label: "Shutter" },
],
},
];
interface TreeRow {
group: TreeGroup;
child?: TreeChild;
}
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
{ group },
...group.children.map((child) => ({ group, child })),
]);
const treeKey = (group: TreeGroup, child: TreeChild) =>
`${group.key}/${child.key}`;
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
@state() private _multiCheckEnd: number | Set<number> = new Set();
@state() private _tree = new Set<string>();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Controlled selection with indeterminate groups">
<ha-list-selectable
multi
controlled
aria-label="Controlled tree"
@ha-list-item-selected=${this._onTreeToggle}
@ha-list-item-deselected=${this._onTreeToggle}
>
${treeGroups.map((group) => {
const groupState = this._groupState(group);
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group.key}
?selected=${groupState === "all"}
?indeterminate=${groupState === "some"}
>
<span slot="headline">${group.label}</span>
</ha-list-item-option>
${group.children.map(
(child) => html`
<ha-list-item-option
class="child"
appearance="checkbox"
selection-position="end"
.value=${treeKey(group, child)}
?selected=${this._tree.has(treeKey(group, child))}
>
<span slot="headline">${child.label}</span>
</ha-list-item-option>
`
)}
`;
})}
</ha-list-selectable>
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return next;
}
private _groupState(group: TreeGroup): "none" | "some" | "all" {
const selected = group.children.filter((child) =>
this._tree.has(treeKey(group, child))
).length;
if (selected === 0) {
return "none";
}
return selected === group.children.length ? "all" : "some";
}
private _onTreeToggle = (ev: CustomEvent<number>) => {
const row = treeRows[ev.detail];
if (!row) {
return;
}
const next = new Set(this._tree);
if (row.child) {
const key = treeKey(row.group, row.child);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
} else {
const select = this._groupState(row.group) !== "all";
row.group.children.forEach((child) => {
const key = treeKey(row.group, child);
if (select) {
next.add(key);
} else {
next.delete(key);
}
});
}
this._tree = next;
};
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
.drag-handle {
cursor: grab;
}
.child::part(base) {
padding-inline-start: var(--ha-space-12);
}
`;
}
@@ -1,4 +1,3 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
@@ -155,9 +153,6 @@ interface Sample {
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
</ha-settings-row>
`
)}
@@ -152,6 +152,84 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
@@ -346,6 +424,23 @@ const SCHEMAS: {
},
},
},
device_class: {
name: "Device Class",
selector: {
device_class: {
domain: "sensor",
},
},
},
device_class_multiple: {
name: "Device Class (Multiple)",
selector: {
device_class: {
domain: "binary_sensor",
multiple: true,
},
},
},
select_custom: {
name: "Select (Custom)",
selector: {
+79 -1
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,6 +340,84 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
@@ -39,6 +39,7 @@ const createConfigEntry = (
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...override,
+19
View File
@@ -16,6 +16,25 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
+1 -11
View File
@@ -15,7 +15,6 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -28,8 +27,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -41,8 +38,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -58,10 +53,7 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
VacuumEntityFeature.RETURN_HOME,
status: "Returning to dock",
},
},
@@ -96,8 +88,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+38 -35
View File
@@ -42,17 +42,17 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.4",
"@codemirror/commands": "6.11.0",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.8",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.4",
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
@@ -75,6 +75,8 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -83,38 +85,37 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.2",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.17",
"hls.js": "1.7.2",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.3",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -142,18 +143,18 @@
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.4.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.2",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -161,20 +162,21 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.4",
"@types/luxon": "3.7.5",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.10",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "5.0.0",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.9",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
"eslint": "10.10.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -184,9 +186,9 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.9.0",
"globals": "17.12.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -195,12 +197,13 @@
"jsdom": "30.0.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.3.0",
"lightningcss": "1.33.0",
"lint-staged": "17.5.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
@@ -210,9 +213,10 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"vitest": "5.0.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -223,12 +227,11 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "[email protected]",
"volta": {
"node": "24.19.0"
"node": "24.21.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260826.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1
View File
@@ -27,6 +27,7 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
@@ -8,4 +8,4 @@ set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
./node_modules/.bin/gulp gen-device-classes
+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-sensor-entity-constants
+7 -3
View File
@@ -62,12 +62,16 @@ export function computeCssColor(color: string): string {
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
* @param style - Styles to resolve against, defaults to the document body
* @returns Resolved color; initial color if not found in the styles
*/
export function resolveThemeColor(color: string): string {
export function resolveThemeColor(
color: string,
style?: CSSStyleDeclaration
): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
const resolved = (style ?? getComputedStyle(document.body))
.getPropertyValue(cssColor)
.trim();
return resolved || color;
+22 -5
View File
@@ -1,4 +1,4 @@
import { wcagLuminance, wcagContrast } from "culori";
import { parse, wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
@@ -51,11 +51,28 @@ export const getRGBContrastRatio = (
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* Tells whether a color can be measured, which a CSS function that is passed
* through unevaluated cannot, and whether it covers what is behind it
* @param color - Color (HEX, rgb/rgba, named color) to check
* @returns Whether a contrast against this color says anything
*/
export const isOpaqueColor = (color: string): boolean => {
const parsed = parse(color.trim());
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
};
/**
* Returns a contrasted color (black or white) for another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
* @returns HEX color, whichever of black and white has the higher contrast ratio
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
const hex = theme2hex(color.trim());
// culori throws on a color it cannot read
if (!parse(hex)) {
return "#ffffff";
}
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
? "#000000"
: "#ffffff";
};
+144
View File
@@ -0,0 +1,144 @@
import type {
Condition,
ConditionContext,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import {
isClientCondition,
isDisabledCondition,
isEntityReference,
isLogicalCondition,
logicalChildren,
} from "./translate";
// Combinators: true / false / undefined (unknown)
const andOf = (values: (boolean | undefined)[]): boolean | undefined => {
let unknown = false;
for (const value of values) {
if (value === false) return false;
if (value === undefined) unknown = true;
}
return unknown ? undefined : true;
};
const orOf = (values: (boolean | undefined)[]): boolean | undefined => {
let unknown = false;
for (const value of values) {
if (value === true) return true;
if (value === undefined) unknown = true;
}
return unknown ? undefined : false;
};
/**
* True when `checkConditionsMet` matches core for this leaf, so we can
* show a result before `subscribe_condition` replies.
*/
const isLocallyEvaluableServerLeaf = (
condition: VisibilityCondition,
states: HomeAssistant["states"],
context: ConditionContext
): boolean => {
const type = "condition" in condition ? condition.condition : "state";
if (type !== "state" && type !== "numeric_state") {
return false;
}
if (!("entity_id" in condition)) {
// Empty `entity: ""` falls back to the host entity, like checkStateCondition.
const target =
("entity" in condition ? condition.entity : undefined) ||
context.entity_id;
return !!target && target in states;
}
const core = condition as {
entity_id?: unknown;
attribute?: unknown;
for?: unknown;
match?: unknown;
value_template?: unknown;
state?: unknown;
above?: unknown;
below?: unknown;
};
if (
typeof core.entity_id !== "string" ||
!(core.entity_id in states) ||
core.attribute !== undefined ||
core.for !== undefined ||
core.match !== undefined ||
core.value_template !== undefined
) {
return false;
}
if (type === "numeric_state") {
const bounds = [core.above, core.below].filter((b) => b !== undefined);
return bounds.length > 0 && bounds.every((b) => typeof b === "number");
}
return !ensureArray(core.state as string | string[] | undefined)?.some(
isEntityReference
);
};
/**
* Evaluate the parts of a visibility tree the client can know exactly.
* Unknown leaves stay unknown (`not: [template]` is not treated as visible).
* Returns `undefined` when the result still depends on core.
*/
export const evaluateConditionsLocally = (
conditions: VisibilityCondition[],
hass: HomeAssistant,
context: ConditionContext
): boolean | undefined => {
const evaluateLeaf = (condition: VisibilityCondition): boolean => {
try {
return checkConditionsMet([condition as Condition], hass, context);
} catch (_err) {
return false;
}
};
const evaluateNode = (
condition: VisibilityCondition
): boolean | undefined => {
// Template `enabled` is only known to core; the local evaluator ignores it.
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
return undefined;
}
if (isLogicalCondition(condition)) {
// Missing `conditions` is treated as true, matching checkAnd/Or/NotCondition.
if (condition.conditions === undefined) {
return true;
}
const values = logicalChildren(condition)
.filter((child) => !isDisabledCondition(child))
.map(evaluateNode);
if (condition.condition === "or") {
return orOf(values);
}
const all = andOf(values);
// Lovelace `not` is NOT(AND of children).
return condition.condition === "not"
? all === undefined
? undefined
: !all
: all;
}
if (
isClientCondition(condition) ||
isLocallyEvaluableServerLeaf(condition, hass.states, context)
) {
return evaluateLeaf(condition);
}
return undefined;
};
// Top-level list is AND. Skip `enabled: false` nodes, as core does.
return andOf(
conditions.filter((c) => !isDisabledCondition(c)).map(evaluateNode)
);
};
+17 -8
View File
@@ -1,17 +1,25 @@
import type {
Condition,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
export function extractMediaQueries(
conditions: VisibilityCondition[]
): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
array.push(...extractMediaQueries(ensureArray(c.conditions)));
}
if (c.condition === "screen" && c.media_query) {
if (
"condition" in c &&
c.condition === "screen" &&
"media_query" in c &&
c.media_query
) {
array.push(c.media_query);
}
return array;
@@ -22,14 +30,15 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
conditions: VisibilityCondition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
array.push(...extractTimeConditions(ensureArray(c.conditions)));
}
if (c.condition === "time") {
array.push(c);
if ("condition" in c && c.condition === "time") {
// Dashboard `time` is always the client lovelace shape.
array.push(c as TimeCondition);
}
return array;
}, []);
+47 -78
View File
@@ -1,10 +1,9 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
Condition,
ConditionContext,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -16,95 +15,65 @@ import { calculateNextTimeUpdate } from "./time-calculator";
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
* Fire onChange at the next time-condition boundary, then reschedule.
* Delays past the setTimeout max are capped and retried without firing.
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
function scheduleTimeBoundaryListener(
getHass: () => HomeAssistant,
timeCondition: Omit<TimeCondition, "condition">,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onBoundary: () => void
): void {
const mediaQueries = extractMediaQueries(conditions);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (mediaQueries.length === 0) return;
const scheduleUpdate = () => {
// Read hass here so a timezone change applies at the next boundary.
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
if (delay === undefined) return;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
onBoundary();
}
});
addListener(unsub);
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
}
/**
* Helper to setup time-based listeners for conditional visibility
* Observe the client-evaluated parts of a condition tree `screen` media
* queries and `time` boundaries and invoke `onChange` whenever one of them
* could have flipped.
*
* This does not evaluate the conditions itself: the caller recombines client
* and server results on notification. Used by `ConditionEvaluatorController`,
* which merges these client signals with the results of `subscribe_condition`
* subscriptions.
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
export function observeConditionChanges(
conditions: VisibilityCondition[],
getHass: () => HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onChange: () => void
): void {
const timeConditions = extractTimeConditions(conditions);
extractMediaQueries(conditions).forEach((mediaQuery) => {
addListener(listenMediaQuery(mediaQuery, () => onChange()));
});
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
extractTimeConditions(conditions).forEach((timeCondition) => {
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
});
}
/**
* Sets up all condition listeners (media query, time) for conditional visibility.
*/
export function setupConditionListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
}
+170
View File
@@ -0,0 +1,170 @@
import type { Condition as CoreCondition } from "../../data/automation";
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import {
isDisabledCondition,
isLogicalCondition,
isServerCondition,
logicalChildren,
translateToCoreCondition,
} from "./translate";
/** One `subscribe_condition` (largest server subtree we can group). */
export interface ServerSubtree {
id: string;
coreCondition: CoreCondition;
}
/** Evaluate a client-only leaf. `undefined` if it cannot be decided yet. */
export type ClientConditionEvaluator = (
condition: VisibilityCondition
) => boolean | undefined;
/** Results by subtree id. `undefined` means not reported yet. */
export type ServerConditionResults = Record<string, boolean | undefined>;
export interface SplitConditionTree {
serverSubtrees: ServerSubtree[];
/**
* Combine client and server results. Returns `undefined` while a needed
* server subtree has not reported.
*/
evaluate: (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
}
type EvalNode = (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
// false wins AND, true wins OR, even if a sibling is still unknown.
const andNode =
(children: EvalNode[]): EvalNode =>
(clientEvaluator, serverResults) => {
let unknown = false;
for (const child of children) {
const value = child(clientEvaluator, serverResults);
if (value === false) return false;
if (value === undefined) unknown = true;
}
return unknown ? undefined : true;
};
const orNode =
(children: EvalNode[]): EvalNode =>
(clientEvaluator, serverResults) => {
let unknown = false;
for (const child of children) {
const value = child(clientEvaluator, serverResults);
if (value === true) return true;
if (value === undefined) unknown = true;
}
return unknown ? undefined : false;
};
const notNode =
(child: EvalNode): EvalNode =>
(clientEvaluator, serverResults) => {
const value = child(clientEvaluator, serverResults);
return value === undefined ? undefined : !value;
};
const serverLeaf =
(id: string): EvalNode =>
(_clientEvaluator, serverResults) =>
serverResults[id];
const clientLeaf =
(condition: VisibilityCondition): EvalNode =>
(clientEvaluator) =>
clientEvaluator(condition);
const unknownLeaf: EvalNode = () => undefined;
/**
* Split a visibility tree into server subscriptions and a local combiner.
*
* Sibling server conditions under the same parent share one subscription.
* `enabled: false` is skipped. A template `enabled` on a client node stays
* unknown. Lovelace `not` is NOT(AND of children).
*/
export const splitConditionTree = (
conditions: VisibilityCondition[]
): SplitConditionTree => {
const serverSubtrees: ServerSubtree[] = [];
let nextId = 0;
const addSubtree = (coreCondition: CoreCondition): EvalNode => {
const id = String(nextId);
nextId += 1;
serverSubtrees.push({ id, coreCondition });
return serverLeaf(id);
};
// Group server siblings into one subscription; recurse into client children.
const buildSiblings = (
children: VisibilityCondition[],
groupOperator: "and" | "or"
): EvalNode[] => {
const serverChildren: VisibilityCondition[] = [];
const clientChildren: VisibilityCondition[] = [];
for (const child of children) {
// Core skips disabled nodes; don't subscribe or combine them.
if (isDisabledCondition(child)) {
continue;
}
(isServerCondition(child) ? serverChildren : clientChildren).push(child);
}
const nodes: EvalNode[] = [];
if (serverChildren.length === 1) {
nodes.push(addSubtree(translateToCoreCondition(serverChildren[0])));
} else if (serverChildren.length > 1) {
nodes.push(
addSubtree({
condition: groupOperator,
conditions: serverChildren.map(translateToCoreCondition),
})
);
}
for (const child of clientChildren) {
nodes.push(build(child));
}
return nodes;
};
const build = (condition: VisibilityCondition): EvalNode => {
// Template `enabled` on a client node can't be rendered here; stay unknown.
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
return unknownLeaf;
}
if (isLogicalCondition(condition)) {
const children = logicalChildren(condition);
if (condition.condition === "or") {
return orNode(buildSiblings(children, "or"));
}
if (condition.condition === "not") {
return notNode(andNode(buildSiblings(children, "and")));
}
return andNode(buildSiblings(children, "and"));
}
// Should only happen if a server leaf slipped past grouping.
if (isServerCondition(condition)) {
return addSubtree(translateToCoreCondition(condition));
}
return clientLeaf(condition);
};
const root = andNode(buildSiblings(conditions, "and"));
return {
serverSubtrees,
evaluate: (clientEvaluator, serverResults) =>
root(clientEvaluator, serverResults),
};
};
+304
View File
@@ -0,0 +1,304 @@
import type {
Condition as CoreCondition,
NumericStateCondition as CoreNumericStateCondition,
StateCondition as CoreStateCondition,
TemplateCondition as CoreTemplateCondition,
} from "../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../data/automation";
import type {
LegacyCondition,
NumericStateCondition as LovelaceNumericStateCondition,
StateCondition as LovelaceStateCondition,
VisibilityCondition,
VisibilityLogicalCondition,
} from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
import { isValidEntityId } from "../entity/valid_entity_id";
/** Client-only types. Never sent to `subscribe_condition`. */
const CLIENT_CONDITION_TYPES = new Set([
"screen",
"user",
"view_columns",
"location",
"time",
]);
const LOGICAL_CONDITION_TYPES = new Set(["and", "or", "not"]);
/** `and` / `or` / `not`. */
export const isLogicalCondition = (
condition: VisibilityCondition
): condition is VisibilityLogicalCondition =>
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
/** Children of a logical condition. Core allows a single item or a list. */
export const logicalChildren = (
condition: VisibilityLogicalCondition
): VisibilityCondition[] => ensureArray(condition.conditions) ?? [];
/** True if `value` looks like an entity id, not a numeric literal. */
export const isEntityReference = (value: unknown): value is string =>
typeof value === "string" && isNaN(Number(value)) && isValidEntityId(value);
/**
* Lovelace `state` / `numeric_state` that core would evaluate differently,
* so existing dashboards keep the client result until the user edits them:
* stringified attributes, any-entity comparison values, entity-id bounds.
*/
const hasLegacyOnlySemantics = (
condition:
LovelaceStateCondition | LovelaceNumericStateCondition | LegacyCondition
): boolean => {
if ("condition" in condition && condition.condition === "numeric_state") {
return [condition.above, condition.below].some(isEntityReference);
}
const state = condition as LovelaceStateCondition | LegacyCondition;
if ("attribute" in state && state.attribute !== undefined) {
return true;
}
return [
...(ensureArray(state.state) ?? []),
...(ensureArray(state.state_not) ?? []),
].some(isEntityReference);
};
/**
* True if this node should go to `subscribe_condition`.
* A mixed `and`/`or`/`not` is client-side so it can wrap server subtrees.
* Lovelace `state`/`numeric_state` with {@link hasLegacyOnlySemantics} stay local.
*/
export const isServerCondition = (condition: VisibilityCondition): boolean => {
if (isLogicalCondition(condition)) {
return logicalChildren(condition).every(isServerCondition);
}
// `{ entity, state }` with no `condition` key is a state condition.
if (!("condition" in condition)) {
return !hasLegacyOnlySemantics(condition);
}
if (CLIENT_CONDITION_TYPES.has(condition.condition)) {
return false;
}
if (
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
!("entity_id" in condition)
) {
return !hasLegacyOnlySemantics(
condition as LovelaceStateCondition | LovelaceNumericStateCondition
);
}
return true;
};
/** `enabled: false`. Core skips these inside `and` / `or` / `not`. */
export const isDisabledCondition = (condition: VisibilityCondition): boolean =>
"enabled" in condition && condition.enabled === false;
/** Inverse of {@link isServerCondition}. True if any leaf is client-side. */
export const isClientCondition = (condition: VisibilityCondition): boolean =>
!isServerCondition(condition);
/** True if every leaf is client-only (no `subscribe_condition` needed). */
export const isPureClientCondition = (
condition: VisibilityCondition
): boolean =>
isLogicalCondition(condition)
? logicalChildren(condition).every(isPureClientCondition)
: isClientCondition(condition);
/**
* Lovelace core automation condition. Already-core types pass through.
* Client-only conditions should not be passed in.
*/
export const translateToCoreCondition = (
condition: VisibilityCondition
): CoreCondition => {
// `{ entity, state, state_not }` with no `condition` key.
if (!("condition" in condition)) {
return translateStateCondition({ condition: "state", ...condition });
}
if (isLogicalCondition(condition)) {
return translateLogicalCondition(condition);
}
switch (condition.condition) {
case "state":
return translateStateCondition(condition as LovelaceStateCondition);
case "numeric_state":
return translateNumericStateCondition(
condition as LovelaceNumericStateCondition
);
default:
return condition as CoreCondition;
}
};
// Always-false: not(and of nothing). Used for incomplete configs so we don't
// emit something core's schema would reject (that would fail a grouped subscription).
const alwaysFalseCondition = (): CoreCondition => ({
condition: "not",
conditions: [{ condition: "and", conditions: [] }],
});
const translateStateCondition = (
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
): CoreCondition => {
if ("entity_id" in condition) {
return condition as CoreStateCondition;
}
const lovelace = condition as LovelaceStateCondition;
const rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
// Missing entity or comparison value: checkConditionsMet is false; core
// would reject the schema.
if (
!lovelace.entity ||
(lovelace.state === undefined && lovelace.state_not === undefined)
) {
return { ...rowConfig, ...alwaysFalseCondition() };
}
const base = {
condition: "state" as const,
entity_id: lovelace.entity,
...(lovelace.attribute !== undefined
? { attribute: lovelace.attribute }
: {}),
};
// Prefer `state` when both are set, matching checkConditionsMet.
if (lovelace.state !== undefined) {
return {
...rowConfig,
...base,
state: lovelace.state,
} as CoreStateCondition;
}
// Core has no `state_not`; wrap a positive `state` in `not`.
return {
...rowConfig,
condition: "not",
conditions: [{ ...base, state: lovelace.state_not } as CoreStateCondition],
};
};
const translateNumericStateCondition = (
condition: LovelaceNumericStateCondition | CoreNumericStateCondition
): CoreCondition => {
if ("entity_id" in condition) {
return condition as CoreNumericStateCondition;
}
const lovelace = condition as LovelaceNumericStateCondition;
const rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
// Missing entity: checkConditionsMet is false; core would reject the schema.
if (!lovelace.entity) {
return { ...rowConfig, ...alwaysFalseCondition() };
}
const above = translateNumericBound(lovelace.above, "above");
const below = translateNumericBound(lovelace.below, "below");
if (typeof above === "symbol" || typeof below === "symbol") {
// `above: +∞` / `below: -∞` can never pass.
return { ...rowConfig, ...alwaysFalseCondition() };
}
if (above === undefined && below === undefined) {
// No usable bound. Lovelace only checks that the value is numeric; core
// requires a bound, so express that as a template.
return {
...rowConfig,
...numericValueCondition(lovelace.entity, lovelace.attribute),
};
}
const core: CoreNumericStateCondition = {
...rowConfig,
condition: "numeric_state",
entity_id: lovelace.entity,
};
if (lovelace.attribute !== undefined) {
core.attribute = lovelace.attribute;
}
if (above !== undefined) {
core.above = above;
}
if (below !== undefined) {
core.below = below;
}
return core;
};
// Numeric-only check when lovelace ignored every bound.
const numericValueCondition = (
entityId: string,
attribute?: string
): CoreTemplateCondition => ({
condition: "template",
value_template:
attribute === undefined
? `{{ is_number(states(${JSON.stringify(entityId)})) }}`
: `{{ is_number(state_attr(${JSON.stringify(entityId)}, ${JSON.stringify(attribute)})) }}`,
});
// Bound that can never pass (see `translateNumericBound`).
const NEVER_SATISFIED = Symbol("never-satisfied");
/**
* Map a lovelace numeric bound onto core.
* Numeric strings become numbers (try Number() first `"10.5"` looks like an id).
* Real entity ids pass through. Junk is dropped. ±Infinity: never-pass sides
* become {@link NEVER_SATISFIED}; always-pass sides are dropped (not JSON-safe).
*/
const translateNumericBound = (
bound: string | number | null | undefined,
side: "above" | "below"
): string | number | undefined | typeof NEVER_SATISFIED => {
// YAML `null` is the same as a missing bound.
if (bound == null) {
return undefined;
}
const numeric = typeof bound === "number" ? bound : Number(bound);
if (isNaN(numeric)) {
return typeof bound === "string" && isValidEntityId(bound)
? bound
: undefined;
}
if (!isFinite(numeric)) {
return (side === "above") === numeric > 0 ? NEVER_SATISFIED : undefined;
}
return numeric;
};
const translateLogicalCondition = (
condition: VisibilityLogicalCondition
): CoreCondition => {
const rowConfig = pickRowConfig(condition, CONDITION_ROW_CONFIG_KEYS);
// Missing `conditions` is treated as true.
if (condition.conditions === undefined) {
return { ...rowConfig, condition: "and", conditions: [] };
}
const conditions = logicalChildren(condition).map(translateToCoreCondition);
if (condition.condition === "not") {
// Lovelace `not` is NOT(AND); core `not` is NOT(OR). Keep the `and`
// wrapper even for one child: a skipped disabled child must stay false.
return {
...rowConfig,
condition: "not",
conditions: [{ condition: "and", conditions }],
};
}
return { ...rowConfig, condition: condition.condition, conditions };
};
@@ -0,0 +1,353 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { subscribeCondition } from "../../data/automation";
import type {
Condition,
ConditionContext,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { observeConditionChanges } from "../condition/listeners";
import { isPureClientCondition } from "../condition/translate";
import type {
ClientConditionEvaluator,
ServerConditionResults,
SplitConditionTree,
} from "../condition/split";
import { splitConditionTree } from "../condition/split";
/** `unknown` until a server subtree reports. */
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
export interface ConditionEvaluatorOptions {
onResult: (result: ConditionEvaluation, error?: string) => void;
/** Wait this long before (re)opening subscriptions after the tree changes. */
resubscribeDelay?: number;
}
const DEFAULT_RESUBSCRIBE_DELAY = 50;
const firstDefined = (
values: Record<string, string | undefined>
): string | undefined => Object.values(values).find((value) => !!value);
/**
* Live evaluation of a visibility tree: `subscribe_condition` for server
* subtrees, local checks for screen/user/time/etc. Call {@link observe} when
* inputs change. Result stays `unknown` until server replies so nothing flashes.
*/
export class ConditionEvaluatorController implements ReactiveController {
private _host: ReactiveControllerHost;
private readonly _onResult: ConditionEvaluatorOptions["onResult"];
private readonly _resubscribeDelay: number;
private _conditions?: VisibilityCondition[];
private _hass?: HomeAssistant;
private _getContext?: () => ConditionContext;
private _connected = false;
// Value of the live tree vs the tree a pending resubscribe will switch to.
// Compared by content so a new array each render does not churn subscriptions.
// `undefined` is a real signature, so pending work uses `_hasPendingResubscribe`.
private _subscribedSignature?: string;
private _pendingSignature?: string;
private _hasPendingResubscribe = false;
// Resubscribe if hass.connection is replaced even when the tree is unchanged.
private _subscribedConnection?: Connection;
// Skip JSON.stringify when the same array is observed again.
private _lastConditionsRef?: VisibilityCondition[];
private _lastSignature?: string;
private _split?: SplitConditionTree;
private _serverResults: ServerConditionResults = {};
private _subtreeErrors: Record<string, string | undefined> = {};
// Template errors from core that still produced a result. Shown in editors,
// but they do not force hidden.
private _subtreeTemplateErrors: Record<string, string | undefined> = {};
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
private _listeners: (() => void)[] = [];
// Ignore callbacks from a torn-down generation.
private _generation = 0;
private _resubscribeTimeout?: ReturnType<typeof setTimeout>;
private _result: ConditionEvaluation = "unknown";
private _error?: string;
private _notifiedResult?: ConditionEvaluation;
private _notifiedError?: string;
constructor(
host: ReactiveControllerHost,
options: ConditionEvaluatorOptions
) {
this._host = host;
this._onResult = options.onResult;
this._resubscribeDelay =
options.resubscribeDelay ?? DEFAULT_RESUBSCRIBE_DELAY;
host.addController(this);
}
public get result(): ConditionEvaluation {
return this._result;
}
public get error(): string | undefined {
return this._error;
}
/**
* Update inputs. Resubscribes only when the tree (or connection) changes.
*/
public observe(
conditions: VisibilityCondition[] | undefined,
hass: HomeAssistant | undefined,
getContext?: () => ConditionContext
): void {
this._conditions = conditions;
this._hass = hass;
this._getContext = getContext;
this._sync();
}
public hostConnected(): void {
this._connected = true;
this._sync();
}
public hostDisconnected(): void {
this._connected = false;
this._teardown();
// Subscriptions are gone; don't keep showing a stale result.
this._notifiedResult = undefined;
this._notifiedError = undefined;
this._setResult("unknown", undefined);
}
private _signatureOf(
conditions: VisibilityCondition[] | undefined
): string | undefined {
if (conditions === undefined) {
return undefined;
}
if (conditions === this._lastConditionsRef) {
return this._lastSignature;
}
this._lastConditionsRef = conditions;
// JSON turns ±Infinity into null; append them so `.inf` bound flips resubscribe.
const nonFinite: string[] = [];
const json = JSON.stringify(conditions, (_key, value) => {
if (typeof value === "number" && !isFinite(value)) {
nonFinite.push(String(value));
}
return value;
});
this._lastSignature = nonFinite.length
? `${json}|${nonFinite.join(",")}`
: json;
return this._lastSignature;
}
private _sync(): void {
if (!this._connected) {
return;
}
const signature = this._signatureOf(this._conditions);
// Resubscribe when the tree content or hass.connection changed.
const targetSignature = this._hasPendingResubscribe
? this._pendingSignature
: this._subscribedSignature;
const connectionReplaced =
this._subscribedConnection !== undefined &&
this._hass !== undefined &&
this._hass.connection !== this._subscribedConnection;
if (signature !== targetSignature || connectionReplaced) {
// Drop the old subscriptions immediately so their result can't leak through.
this._teardown();
this._hasPendingResubscribe = true;
this._pendingSignature = signature;
if (
this._conditions === undefined ||
this._conditions.every(isPureClientCondition)
) {
// All client-side; no server subscription to debounce.
this._subscribe();
return;
}
this._scheduleResubscribe();
}
// Keep client leaves live. Pending resubscribe has no split → `unknown`.
this._recompute();
}
private _scheduleResubscribe(): void {
if (this._resubscribeTimeout !== undefined) {
clearTimeout(this._resubscribeTimeout);
}
this._resubscribeTimeout = setTimeout(() => {
this._resubscribeTimeout = undefined;
this._subscribe();
}, this._resubscribeDelay);
}
private _subscribe(): void {
this._teardown();
const conditions = this._conditions;
const hass = this._hass;
this._pendingSignature = undefined;
this._hasPendingResubscribe = false;
if (!conditions || !hass) {
// Don't mark subscribed yet; hass often arrives after connect.
this._setResult("unknown", undefined);
return;
}
this._subscribedSignature = this._signatureOf(conditions);
const split = splitConditionTree(conditions);
this._split = split;
const generation = this._generation;
const connection: Connection = hass.connection;
this._subscribedConnection = connection;
for (const subtree of split.serverSubtrees) {
this._serverResults[subtree.id] = undefined;
const subscription = subscribeCondition(
connection,
(message) => {
if (generation !== this._generation) {
return;
}
if (message.error !== undefined) {
this._serverResults[subtree.id] = false;
this._subtreeErrors[subtree.id] =
typeof message.error === "string"
? message.error
: message.error.message;
} else {
this._serverResults[subtree.id] = message.result;
this._subtreeErrors[subtree.id] = undefined;
}
this._subtreeTemplateErrors[subtree.id] = message.template_errors
?.length
? message.template_errors.join("\n")
: undefined;
this._recompute();
},
subtree.coreCondition
);
subscription.catch((err: unknown) => {
if (generation !== this._generation) {
return;
}
this._serverResults[subtree.id] = false;
this._subtreeErrors[subtree.id] =
err instanceof Error ? err.message : String(err);
this._recompute();
});
this._subscriptions.push(subscription);
}
observeConditionChanges(
conditions,
() => this._hass ?? hass,
(unsub) => this._listeners.push(unsub),
() => this._recompute()
);
this._recompute();
}
private _recompute(): void {
if (!this._split || !this._hass) {
this._setResult("unknown", undefined);
return;
}
const hass = this._hass;
const context = this._getContext?.() ?? {};
const clientEvaluator: ClientConditionEvaluator = (condition) => {
try {
return checkConditionsMet([condition as Condition], hass, context);
} catch (_err) {
return false;
}
};
const error = firstDefined(this._subtreeErrors);
// Don't treat a server error as `false` that a client `not` would invert.
if (error !== undefined) {
this._setResult("hidden", error);
return;
}
const value = this._split.evaluate(clientEvaluator, this._serverResults);
const result: ConditionEvaluation =
value === undefined ? "unknown" : value ? "visible" : "hidden";
this._setResult(result, firstDefined(this._subtreeTemplateErrors));
}
private _setResult(
result: ConditionEvaluation,
error: string | undefined
): void {
this._result = result;
this._error = error;
if (result === this._notifiedResult && error === this._notifiedError) {
return;
}
this._notifiedResult = result;
this._notifiedError = error;
this._onResult(result, error);
this._host.requestUpdate();
}
private _teardown(): void {
this._generation += 1;
if (this._resubscribeTimeout !== undefined) {
clearTimeout(this._resubscribeTimeout);
this._resubscribeTimeout = undefined;
}
for (const subscription of this._subscriptions) {
subscription.then((unsub) => unsub()).catch(() => undefined);
}
this._subscriptions = [];
for (const unsub of this._listeners) {
unsub();
}
this._listeners = [];
this._split = undefined;
this._serverResults = {};
this._subtreeErrors = {};
this._subtreeTemplateErrors = {};
this._subscribedSignature = undefined;
this._pendingSignature = undefined;
this._hasPendingResubscribe = false;
this._subscribedConnection = undefined;
}
}
@@ -1,59 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type {
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { setupConditionListeners } from "../condition/listeners";
/**
* Reactive controller that manages the media-query and time-based listeners
* needed to keep a set of lovelace visibility conditions evaluated live.
*
* The host is responsible for the actual evaluation (e.g. computing visible /
* hidden / invalid state); the controller only triggers it via the supplied
* `onUpdate` callback when something the conditions depend on changes. Call
* `setup()` whenever the conditions change; the controller clears previous
* listeners and re-subscribes. Listeners are automatically released when the
* host disconnects.
*/
export class ConditionListenersController implements ReactiveController {
private _unsubs: (() => void)[] = [];
constructor(host: ReactiveControllerHost) {
host.addController(this);
}
public hostDisconnected(): void {
this.clear();
}
public setup(
conditions: Condition[],
hass: HomeAssistant,
onUpdate: () => void,
getContext?: () => ConditionContext
): void {
this.clear();
if (!conditions.length) {
return;
}
setupConditionListeners(
conditions,
hass,
(unsub) => this._unsubs.push(unsub),
() => onUpdate(),
getContext
);
}
public clear(): void {
for (const unsub of this._unsubs) {
unsub();
}
this._unsubs = [];
}
}
@@ -0,0 +1,216 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish(animation);
this._animation = animation;
}
private async _finish(animation?: Animation) {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
await this.host.updateComplete;
}
if (this._animation !== animation) {
return;
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
@@ -0,0 +1,152 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HaSlider } from "../../components/ha-slider";
import type { MediaPlayerEntity } from "../../data/media-player";
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
const PENDING_SEEK_TIMEOUT_MS = 5000;
const PENDING_SEEK_TOLERANCE_S = 2;
const PROGRESS_INTERVAL_MS = 1000;
export interface MediaProgressControllerOptions {
getStateObj: () => MediaPlayerEntity | undefined;
getSlider: () => HaSlider | undefined;
}
/**
* Drives a media progress slider: ticks it while the media plays, holds it
* on the target after a seek until the player state catches up, and leaves
* it alone while the user drags it. The controller owns the slider value;
* the host must not bind `.value` and reads `progress` for position text.
*/
export class MediaProgressController implements ReactiveController {
/** Current position in media seconds, pending seek included. */
public progress?: number;
private _host: ReactiveControllerHost;
private _options: MediaProgressControllerOptions;
private _interval?: number;
private _pendingPosition?: number;
private _pendingSince = 0;
constructor(
host: ReactiveControllerHost,
options: MediaProgressControllerOptions
) {
this._host = host;
this._options = options;
host.addController(this);
}
public hostUpdate(): void {
this._computeProgress();
}
public hostUpdated(): void {
this._writeSlider();
this._syncInterval();
}
public hostDisconnected(): void {
this._stopInterval();
}
/**
* Report a seek so the displayed position moves to the target immediately
* instead of jumping back until the player state reflects the seek.
*/
public seek(position: number): void {
this._pendingPosition = position;
this._pendingSince = Date.now();
this._tick();
}
private _tick(): void {
this._computeProgress();
this._writeSlider();
this._host.requestUpdate();
}
private _syncInterval(): void {
const stateObj = this._options.getStateObj();
if (
stateObj?.state === "playing" &&
stateObj.attributes.media_duration &&
stateObj.attributes.media_position !== undefined
) {
if (!this._interval) {
this._interval = window.setInterval(
() => this._tick(),
PROGRESS_INTERVAL_MS
);
}
} else {
this._stopInterval();
}
}
private _stopInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _computeProgress(): void {
const stateObj = this._options.getStateObj();
if (
!stateObj ||
!stateObj.attributes.media_duration ||
stateObj.attributes.media_position === undefined
) {
this.progress = undefined;
return;
}
this.progress = this._applyPendingSeek(
getCurrentProgress(stateObj),
stateObj.state === "playing",
stateObj.attributes.media_duration
);
}
private _applyPendingSeek(
current: number,
playing: boolean,
duration: number
): number {
if (this._pendingPosition === undefined) {
return current;
}
const elapsedMs = Date.now() - this._pendingSince;
const target = Math.min(
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
duration
);
if (
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
) {
this._pendingPosition = undefined;
return current;
}
return target;
}
private _writeSlider(): void {
const slider = this._options.getSlider();
if (!slider) {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
return;
}
slider.value = this.progress ?? 0;
}
}
@@ -0,0 +1,70 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+12
View File
@@ -57,6 +57,18 @@ export const formatDurationLong = (
duration: HaDurationData
) => formatDurationLongMem(locale).format(duration);
const formatDurationNarrowMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
})
);
export const formatDurationNarrow = (
locale: FrontendLocaleData,
duration: HaDurationData
) => formatDurationNarrowMem(locale).format(duration);
const formatDigitalDurationMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
+19
View File
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
+5 -10
View File
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
element.__themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles
if (window.ShadyCSS) {
// Use ShadyCSS if available
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
} else {
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
}
};
+32 -21
View File
@@ -1,13 +1,27 @@
import type { Map, TileLayer } from "leaflet";
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
token?: string;
}
): Promise<LeafletMapSetup> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -17,7 +31,11 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -38,21 +56,14 @@ export const setupLeafletMap = async (
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.token
);
return { map, leaflet: Leaflet, baseLayer };
};
-25
View File
@@ -1,25 +0,0 @@
// Toggle Attribute Polyfill because it's too new for some browsers
export const toggleAttribute = (
el: HTMLElement,
name: string,
force?: boolean
) => {
if (force !== undefined) {
force = !!force;
}
if (el.hasAttribute(name)) {
if (force) {
return true;
}
el.removeAttribute(name);
return false;
}
if (force === false) {
return false;
}
el.setAttribute(name, "");
return true;
};
+4 -1
View File
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
};
export const generateEntityFilter = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
+3
View File
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
cover: {
speed: "supported_speeds",
},
event: {
event_type: "event_types",
},
@@ -1 +0,0 @@
export const webComponentsSupported = "attachShadow" in Element.prototype;
-50
View File
@@ -1,50 +0,0 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
import { directive, Directive } from "lit-html/directive.js";
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);
+333
View File
@@ -0,0 +1,333 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import {
MAP_TILES_PATH,
mapTilesUrl,
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
export const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
// The demo has no proxy to go through. Upstream serves raster to a browser that
// identifies itself with a referrer, which the demo page's `same-origin` meta
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
// OSM's raster stops at 19 and the proxy refuses higher, so Leaflet scales the
// last level up rather than asking for tiles that are not there.
const RASTER_MAX_NATIVE_ZOOM = 19;
// Leaflet substitutes any option into the URL template; its types do not.
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
}
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
export const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
webGL2Supported = Boolean(context);
// Contexts are scarce; the probe must not keep one.
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webGL2Supported = false;
}
}
return webGL2Supported;
};
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
// mangle its {fontstack} and {range} placeholders.
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
// its glyphs and sprites, which is why those come from VersaTiles.
const DEMO_UPSTREAM = {
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
assets: "https://tiles.versatiles.org/assets",
};
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
style.sprite = [
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
];
Object.values(style.sources).forEach((source) => {
if ("url" in source) {
source.url = DEMO_UPSTREAM.tilejson;
}
});
return style;
};
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
return useDemoUpstream(style);
}
if (typeof style.sprite === "string") {
style.sprite = mapTilesUrl(style.sprite);
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: mapTilesUrl(sprite.url),
}));
}
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
// OSM asks a website for a referrer, and the demo has no instance
// hostname to leak.
referrerPolicy: __DEMO__ ? "origin" : undefined,
}),
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
// blocked worker throws here. Keep it guarded or those lose the fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
let refused = false;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
let contextLost = false;
// Declared first, but only ever called once all three exist.
const handleVisibilityChange = () => {
if (contextLost) {
scheduleSwap();
}
};
const swapToRaster = () => {
vector = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
try {
layer.remove();
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map, token);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding drops it too, and there it comes back on return.
if (!vector || document.hidden) {
return;
}
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
};
glMap.on("webglcontextlost", () => {
contextLost = true;
scheduleSwap();
});
glMap.on("webglcontextrestored", () => {
contextLost = false;
clearTimeout(fallbackTimeout);
});
document.addEventListener("visibilitychange", handleVisibilityChange);
map.on("unload", () => {
// Otherwise the timer revives a map that is already gone.
clearTimeout(fallbackTimeout);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
const applyStyle = (newDarkMode: boolean) => {
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
};
// A refused request leaves the source dead: the TileJSON is fetched once and
// is never retried, so the style has to be applied again once there is a new
// token. Throttled, or a proxy refusing for another reason loops.
let lastRecovery = 0;
glMap.on("error", (event) => {
const status = (event.error as { status?: number } | undefined)?.status;
// 403 is a stale token, 404 the proxy not registered yet during a restart,
// and no status at all a network failure. All three recover the same way,
// and a token that comes back unchanged costs nothing.
if (status !== undefined && status !== 403 && status !== 404) {
return;
}
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
return;
}
lastRecovery = Date.now();
refused = true;
refreshMapTilesToken();
});
// Only a new token clears the refusal. A theme change in between applies a
// style that is refused just as the last one was, so it proves nothing.
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
refused = false;
applyStyle(requestedDarkMode);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap,
token: string | undefined
): MapBaseLayer => {
const layer = leaflet
.tileLayer(__DEMO__ ? DEMO_RASTER_TILE_URL : mapTilesUrl(RASTER_TILE_URL), {
attribution: OSM_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
maxNativeZoom: RASTER_MAX_NATIVE_ZOOM,
referrerPolicy: __DEMO__ ? "origin" : undefined,
// Leaflet throws on an undefined template variable, so no token means an
// empty one: the tiles 403 and the markers still draw.
token: token ?? "",
} as TokenTileLayerOptions)
.addTo(map);
// Substituted per request, so a refreshed token needs no new layer.
const unsubscribe = subscribeMapTilesToken((newToken) => {
(layer.options as TokenTileLayerOptions).token = newToken;
// Tiles that 403'd are cached as failures; only a redraw asks again.
layer.redraw();
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
): Promise<MapBaseLayer> => {
if (!rasterOnly && supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode,
token
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map, token);
};
+59
View File
@@ -0,0 +1,59 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
/** Advertised slider maximum; a larger radius raises it (see the engine) */
export const RADIUS_ARIA_MAX = 100000;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -0,0 +1,353 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
paddingBottomRight: [
options?.padding?.right ?? 0,
options?.padding?.bottom ?? 0,
],
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}

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