Compare commits

..
Author SHA1 Message Date
Paul Bottein 7f45689676 Migrate legacy sun and calendar offsets in the trigger editor 2026-09-10 13:46:28 +02:00
Paul Bottein 6ccc78114c Add offset selector 2026-09-10 13:46:10 +02:00
Paul Bottein ec4bfc9255 Add parent device to entity context (#53766)
* Add parent device to entity context

* Fix parent device context display edge cases

* Fix parent device context in suggestions and completions

* Order entity name parts by hierarchy

* Remove unused parent device missing string

* Add parent device to logbook entry context

* Show inherited area and parent device for device targets

* Test parent device search in the entity tree

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

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

* Pass registries to memoized entity name lists

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

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

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

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

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

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

* Keep the retained circle data current while resizing

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

* Address code review on map editing

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

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

* Test the map editing path

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

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

* Leave zone row selection to the user

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

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

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

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

* Guard a keyboard resize against host updates like a drag

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

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

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

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

* Show the fallback notice only when a location is editable

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

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

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

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

* Announce the fallback notice, and let test frames redraw

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

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

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

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

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

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

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

* Drop the zone page's active entry

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

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

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

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

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

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

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

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


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

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

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

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

* Use automation descriptions instead of target ui

* Type check

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

* Format condition editor

* Fix contextual condition descriptions

* Resolve state references in condition descriptions

* Share automation condition headers with visibility conditions

---------

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

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

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

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

* Enhance keyboard accessibility for icon click

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

* Fix dark mode style application logic

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

* Address code review on the map engine

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

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

* Address second code review round on the map engine

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

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

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

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

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

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

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

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

* Tear down an engine that fails while still setting up

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

* Make history point popups readable on MapLibre

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

---------

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

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

Fix show_values label alignment on Sankey nodes

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

* Address review feedback on show_values

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

* Apply batched suggestions from code review

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

* Format Sankey card templates with Prettier

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

* Fix vertical Sankey value labels wrapping on thin nodes

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

* Reserve extra bottom space for vertical Sankey values

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

* Keep locale grouping spaces in Sankey value labels

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

---------

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

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

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

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

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

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

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

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

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

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

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

* Modbus: a serial port is open, not connected

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

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-09 07:08:22 +02:00
Aidan Timson 64cda9e0e7 Clarify companion skill selection before frontend review (#54067) 2026-09-08 20:21:56 +02:00
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
174 changed files with 13760 additions and 3784 deletions
+6 -1
View File
@@ -7,6 +7,12 @@ description: Home Assistant frontend PR and review guidance, including implement
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Review Preparation
1. Establish the review scope from the diff and the behavior changed by it.
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
@@ -116,4 +122,3 @@ For user-facing changes, establish the existing design context as part of fronte
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
+1 -1
View File
@@ -1 +1 @@
24.20.0
24.21.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -37,7 +37,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
## Project Skills
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
+2
View File
@@ -81,6 +81,8 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
+2
View File
@@ -1,6 +1,7 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { modbusFixtures } from "./modbus/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
@@ -16,6 +17,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
modbusFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
+2
View File
@@ -1,6 +1,7 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockModbus } from "./modbus/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
@@ -12,6 +13,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
const MOCKS = [
mockBluetooth,
mockSerial,
mockModbus,
mockMatter,
mockMqtt,
mockZwaveJs,
@@ -0,0 +1,47 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const FLEXIT_ENTRY_ID = "mock-flexit";
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
export const FRONIUS_ENTRY_ID = "mock-fronius";
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("flexit", "Flexit", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "device",
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
},
{
type: "device",
entry: configEntry(
SOLAREDGE_ENTRY_ID,
"solaredge_modbus",
"SolarEdge SE7K"
),
},
{
type: "device",
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
},
],
};
@@ -0,0 +1,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 }));
};
+26 -2
View File
@@ -1,5 +1,12 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
// The RS-485 port below carries the Modbus connections, so both sides name the
// same config entries.
import {
FLEXIT_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "../modbus/fixtures";
const PORTS: SerialPortUsage[] = [
{
@@ -55,7 +62,7 @@ const PORTS: SerialPortUsage[] = [
discovery_flows: [],
},
{
device: "/dev/ttyUSB1",
device: RS485_PORT,
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
@@ -68,7 +75,24 @@ const PORTS: SerialPortUsage[] = [
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [],
consumers: [
{
kind: "config_entry",
title: "Flexit Nordic S4",
active: true,
domain: "flexit",
config_entry_id: FLEXIT_ENTRY_ID,
slug: null,
},
{
kind: "config_entry",
title: "SolarEdge SE7K",
active: true,
domain: "solaredge_modbus",
config_entry_id: SOLAREDGE_ENTRY_ID,
slug: null,
},
],
discovery_flows: [],
},
{
@@ -319,6 +319,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
app: { name: "App", selector: { app: {} } },
number_box: {
name: "Number Box",
+5 -6
View File
@@ -110,7 +110,6 @@
"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",
@@ -163,7 +162,6 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
@@ -172,7 +170,7 @@
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "4.1.11",
"@vitest/coverage-v8": "5.0.0",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.9",
@@ -200,7 +198,7 @@
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.4.1",
"lint-staged": "17.5.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -216,8 +214,9 @@
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"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"
@@ -233,6 +232,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.20.0"
"node": "24.21.0"
}
}
+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,31 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export const durationValueToData = (
value?: HaDurationData | string | number
): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
};
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
type: EntityNameType;
}
| {
type: "text";
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, area, floor } = getEntityContext(
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
entities,
devices,
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
parentDevice: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
@@ -31,6 +32,7 @@ export const getEntityContext = (
return {
entity: null,
device: null,
parentDevice: null,
area: null,
floor: null,
};
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const parentDevice = device?.parent_device_id
? devices[device.parent_device_id]
: undefined;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
return {
entity: entity,
device: device || null,
parentDevice: parentDevice || null,
area: area || null,
floor: floor || null,
};
+11 -9
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
const VECTOR_STYLES = {
export const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
export const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
const loadStyle = async (url: string): Promise<StyleSpecification> => {
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,9 +303,11 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
if (!rasterOnly && supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
+59
View File
@@ -0,0 +1,59 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
/** Advertised slider maximum; a larger radius raises it (see the engine) */
export const RADIUS_ARIA_MAX = 100000;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -0,0 +1,348 @@
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,
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
/**
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input; defaults to true */
interactive?: boolean;
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
focusable?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
readonly clusterData?: unknown;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
/**
* The point a distance away from center along a bearing (degrees clockwise
* from north), on the great circle. Longitude is left unwrapped so a ring of
* points stays continuous across the antimeridian.
*/
export const destinationPoint = (
center: MapLatLng,
distanceInMeters: number,
bearingDegrees: number
): MapLatLng => {
const angular = distanceInMeters / EARTH_RADIUS;
const lat = toRadians(center[0]);
const bearing = toRadians(bearingDegrees);
const destLat = Math.asin(
Math.sin(lat) * Math.cos(angular) +
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
);
const dLng = Math.atan2(
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
);
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
};
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const dLat = toRadians(b[0] - a[0]);
const dLng = toRadians(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(a[0])) *
Math.cos(toRadians(b[0])) *
Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
/**
* Bounding box corners of a circle, for fitting a radius into view. A circle
* that reaches a pole spans every longitude.
*/
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const angular = radiusMeters / EARTH_RADIUS;
const latMin = Math.max(-90, center[0] - toDegrees(angular));
const latMax = Math.min(90, center[0] + toDegrees(angular));
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
return [
[latMin, -180],
[latMax, 180],
];
}
const dLng = toDegrees(Math.asin(sinRatio));
return [
[latMin, center[1] - dLng],
[latMax, center[1] + dLng],
];
};
+53
View File
@@ -0,0 +1,53 @@
/**
* Marker elements may be focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*
* What the engine sets is recorded on the element, so a rebuilt marker on a
* reused element gets fresh attributes while the caller's own are left alone.
*/
const OWNED = "haMapA11y";
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
focusable: boolean
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
if (!element.hasAttribute("role")) {
if (focusable) {
element.setAttribute("role", "button");
owned.push("role");
} else if (title) {
element.setAttribute("role", "img");
owned.push("role");
} else {
element.setAttribute("aria-hidden", "true");
owned.push("aria-hidden");
}
}
if (focusable && !element.hasAttribute("tabindex")) {
element.tabIndex = 0;
owned.push("tabindex");
}
element.dataset[OWNED] = owned.join(" ");
};
/** Removes what setMarkerAccessibility set on the element */
export const clearMarkerAccessibility = (element: HTMLElement): void => {
const owned = element.dataset[OWNED];
if (owned === undefined) {
return;
}
owned
.split(" ")
.filter(Boolean)
.forEach((name) => element.removeAttribute(name));
delete element.dataset[OWNED];
};
-2
View File
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
@@ -0,0 +1,154 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
+54 -16
View File
@@ -43,6 +43,8 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@@ -55,6 +57,9 @@ export class HaSankeyChart extends LitElement {
@property({ type: Boolean }) public vertical = false;
@property({ type: Boolean, attribute: "show-values" }) public showValues =
false;
@property({ attribute: false }) public valueFormatter?: (
value: number
) => string;
@@ -84,7 +89,11 @@ export class HaSankeyChart extends LitElement {
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.data=${this._createData(
this.data,
this._sizeController.value?.width,
this.showValues
)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@@ -142,7 +151,11 @@ export class HaSankeyChart extends LitElement {
}
};
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
private _computeData = (
data: SankeyChartData,
width = 0,
showValues = false
) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>();
@@ -183,6 +196,10 @@ export class HaSankeyChart extends LitElement {
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
const verticalBottom = showValues
? LABEL_DISTANCE + FONT_SIZE * 3 + OVERFLOW_MARGIN
: 25;
return {
id: "sankey",
@@ -209,29 +226,48 @@ export class HaSankeyChart extends LitElement {
layoutIterations: 0,
animationDuration: 500,
label: {
formatter: (params) =>
data.nodes.find((node) => node.id === (params.data as Node).id)
?.label ?? (params.data as Node).id,
formatter: (params) => {
const nodeData = params.data as { id: string; value: number };
const node = data.nodes.find((n) => n.id === nodeData.id);
const label = node?.label ?? nodeData.id;
if (!showValues || !nodeData.id) return label;
const formatted = this.valueFormatter
? this.valueFormatter(nodeData.value).trim()
: String(nodeData.value);
// LRM keeps numeric values LTR on the canvas without creating wrap points.
return `${label}\n\u200E${formatted}`;
},
position: this.vertical ? "bottom" : "right",
distance: LABEL_DISTANCE,
minMargin: 5,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
},
labelLayout: (params) => {
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
.split(" ")
.reduce(
(longest, current) =>
longest.length > current.length ? longest : current,
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
.replace(BIDI_MARKS, "")
.split(/[ \n]+/)
.reduce((longest, current) => {
if (!current) {
return longest;
}
if (!longest) {
return current;
}
return measureTextWidth(current, FONT_SIZE) >
measureTextWidth(longest, FONT_SIZE)
? current
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
@@ -254,14 +290,16 @@ export class HaSankeyChart extends LitElement {
};
},
top: this.vertical ? 0 : OVERFLOW_MARGIN,
bottom: this.vertical ? 25 : OVERFLOW_MARGIN,
bottom: this.vertical ? verticalBottom : OVERFLOW_MARGIN,
left: this.vertical ? OVERFLOW_MARGIN : 0,
right: this.vertical ? OVERFLOW_MARGIN : labelSpace + LABEL_DISTANCE,
emphasis: {
focus: "adjacency",
},
} as SankeySeriesOption;
});
};
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
@@ -23,6 +23,8 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +43,11 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render each row's name inside the plot (under its bar) instead of in a
// left-hand category-label column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -70,7 +77,11 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${
this.data.length *
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -185,6 +196,7 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
@@ -196,9 +208,11 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
@@ -227,31 +241,47 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
// Draw the name inside the plot, under each row's bar, matching
// the line charts whose legend sits under the plot. The taller
// rows keep a name clear of the next row's bar.
show: showNames,
inside: true,
margin: 0,
padding: [18, 0, 0, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "top",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
@@ -79,6 +79,11 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render timeline row names inside the plot (under each bar) instead of in a
// left-hand column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +232,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -444,6 +450,10 @@ export class StateHistoryCharts extends LitElement {
margin-top: 16px;
}
.entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.container,
lit-virtualizer {
height: 100%;
+11 -8
View File
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import {
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
@@ -170,6 +171,7 @@ export class HaEntityNamePicker extends LitElement {
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
@@ -387,6 +389,7 @@ export class HaEntityNamePicker extends LitElement {
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
const types = this._validTypes(entityId);
const items = (
["entity", "device", "area", "floor"] as const
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
const excludedValues = new Set(
this._items
.filter((item) => UNIQUE_TYPES.has(item.type))
.filter((item) => KNOWN_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
),
});
}
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
+41 -18
View File
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
+28 -3
View File
@@ -50,7 +50,11 @@ export class HaAnalytics extends LitElement {
.preference=${"base"}
.disabled=${loading}
name="base"
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
</ha-row-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
@@ -72,7 +76,11 @@ export class HaAnalytics extends LitElement {
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
${
baseEnabled
? nothing
@@ -106,7 +114,11 @@ export class HaAnalytics extends LitElement {
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
></ha-switch>
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
</ha-row-item>
`;
}
@@ -143,6 +155,19 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
/* The visible headline already names the row. Keep the switch's
slotted label available to assistive technology without repeating it. */
ha-switch::part(label) {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
+19 -9
View File
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
+12
View File
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
});
}
if (parentDeviceName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.device-picker.parent_device"
),
value: parentDeviceName,
});
}
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
+24 -29
View File
@@ -77,9 +77,8 @@ export class HaFilterDevices extends LitElement {
super.willUpdate(properties);
if (
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
@@ -125,7 +124,7 @@ export class HaFilterDevices extends LitElement {
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem(this.value)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
@@ -135,20 +134,18 @@ export class HaFilterDevices extends LitElement {
`;
}
private _renderItem = memoizeOne(
(value: string[] | undefined) => (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`
);
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
@@ -214,11 +211,9 @@ export class HaFilterDevices extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!value?.length) {
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
@@ -227,25 +222,25 @@ export class HaFilterDevices extends LitElement {
return;
}
for (const deviceId of value) {
if (type) {
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: type ? items : undefined,
items: this.type ? items : undefined,
});
}
+16 -19
View File
@@ -79,9 +79,8 @@ export class HaFilterFloorAreas extends LitElement {
super.willUpdate(properties);
if (
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
@@ -279,11 +278,12 @@ export class HaFilterFloorAreas extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!value || (!value.areas?.length && !value.floors?.length)) {
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
value: {},
@@ -292,36 +292,33 @@ export class HaFilterFloorAreas extends LitElement {
return;
}
if (value.areas) {
for (const areaId of value.areas) {
if (type) {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
}
}
}
if (value.floors) {
for (const floorId of value.floors) {
if (type) {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: type ? items : undefined,
value: this.value,
items: this.type ? items : undefined,
});
}
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
offset: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
offset: () => ({ type: "none" }),
period: undefined,
qr_code: undefined,
select: (selector) => {
@@ -1,6 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import type { DurationSelector } from "../../data/selector";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
return this._input?.reportValidity() ?? true;
}
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
private _data = memoizeOne(durationValueToData);
protected render() {
return html`
@@ -0,0 +1,182 @@
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
OffsetSelector,
OffsetSelectorValue,
OffsetType,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import "../ha-input-helper-text";
import "../ha-select";
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
@customElement("ha-selector-offset")
export class HaOffsetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: OffsetSelector;
@property({ attribute: false }) public value?: OffsetSelectorValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
public reportValidity(): boolean {
return this._durationInput?.reportValidity() ?? true;
}
private get _value(): OffsetSelectorValue | undefined {
return isOffsetValue(this.value) ? this.value : undefined;
}
private _duration = memoizeOne(durationValueToData);
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.offset.${value}`),
}))
);
protected render() {
const type = this._value?.type ?? "none";
return html`
<div class="container">
${
this.label
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing
}
<div class="inputs">
<ha-select
.value=${type}
.options=${this._typeOptions(this.hass.localize)}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${
type !== "none"
? html`<div class="value-row">
<span class="value-label"
>${this.hass.localize(
"ui.components.selectors.offset.duration"
)}${this.required ? "*" : ""}</span
>
<ha-duration-input
.data=${this._duration(this._value?.duration)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.offset?.enable_day}
.enableMillisecond=${
this.selector.offset?.enable_millisecond
}
@value-changed=${this._durationChanged}
></ha-duration-input>
</div>`
: nothing
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
ev.stopPropagation();
const type = this._value?.type;
if (!type || type === "none") {
return;
}
fireEvent(this, "value-changed", {
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
});
}
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const type = ev.detail.value as OffsetType | undefined;
if (!type || type === this._value?.type) {
return;
}
fireEvent(this, "value-changed", {
value:
type === "none"
? { type }
: {
type,
duration:
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
},
});
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-offset": HaOffsetSelector;
}
}
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
offset: () => import("./ha-selector-offset"),
period: () => import("./ha-selector-period"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
+1 -8
View File
@@ -192,14 +192,7 @@ export class HaListVirtualized extends HaListBase {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
try {
await this.virtualizerElement?.layoutComplete;
} catch (err) {
if (err === "disconnected") {
return;
}
throw err;
}
await this.virtualizerElement?.layoutComplete;
this._applySetSize();
if (!this.virtualizerElement) {
+18
View File
@@ -58,6 +58,24 @@ class HaEntityMarker extends LitElement {
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeydown);
}
// The map engines make the marker a focusable button
private _handleKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._badgeTap(ev);
}
};
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
+160 -234
View File
@@ -1,22 +1,19 @@
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { ThemeMode } from "../../types";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
@@ -41,8 +38,16 @@ export interface MarkerLocation {
radius_color?: string;
location_editable?: boolean;
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -59,35 +64,20 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
private Leaflet?: LeafletModuleType;
@state() private _editingAvailable = true;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: LatLngExpression[],
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -97,42 +87,50 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
return;
}
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
} else {
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
this.map.setView(center, options?.zoom || this.zoom);
}
}
protected render(): TemplateResult {
return html`
<ha-map
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
></ha-map>
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable &&
this._i18n &&
this.locations?.some(
(location) => location.location_editable || location.radius_editable
)
? html`<ha-alert alert-type="warning">
${this._i18n.localize("ui.components.map.editing_unavailable")}
</ha-alert>`
: ""
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -141,63 +139,102 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
}
return layers;
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
}));
}
);
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
// Still loading.
if (!this.Leaflet) {
return;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const key = JSON.stringify([
location.icon,
location.iconPath,
location.name,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
if (changedProps.has("locations")) {
this._updateMarkers();
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
fireEvent(this, "markers-updated");
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations[idx] ||
!oldLocations?.[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
);
if (movedLocations?.length === 1) {
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -206,40 +243,37 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
fireEvent(
this,
"location-updated",
{ id: marker.id, location },
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ bubbles: false }
);
}
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
fireEvent(
this,
"radius-updated",
{ id: marker.id, radius: circle.getRadius() },
{ id: ev.detail.id, radius: ev.detail.radius },
{ bubbles: false }
);
}
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
}
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -248,136 +282,28 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
this.map.panTo(location);
}
}
}
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
.map {
position: relative;
height: 100%;
}
ha-map {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
}
File diff suppressed because it is too large Load Diff
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
isRTL: boolean,
stateObj: HassEntity
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -21,7 +21,6 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
@@ -30,6 +29,7 @@ import {
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
@@ -132,10 +132,35 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
private _loadedConfigEntryId?: string;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
if (
changedProps.has("itemId") ||
changedProps.has("type") ||
changedProps.has("hass")
) {
this._updateDomain();
}
}
private _updateDomain() {
if (this.type === "entity") {
this._setDomainName(computeDomain(this.itemId));
return;
}
if (this.type !== "device") {
return;
}
const configEntryId =
this.hass.devices?.[this.itemId]?.primary_config_entry;
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
this._loadedConfigEntryId = configEntryId;
this._getDeviceDomain(configEntryId);
}
}
protected render() {
@@ -674,7 +699,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
}
private _itemData = memoizeOne((type: TargetType, item: string) => {
private _itemData(type: TargetType, item: string) {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
return {
@@ -697,9 +722,25 @@ export class HaTargetPickerItemRow extends LitElement {
if (type === "device") {
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: device
@@ -709,19 +750,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.states
)
: item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
context,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
@@ -729,10 +768,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined };
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
@@ -760,7 +806,7 @@ export class HaTargetPickerItemRow extends LitElement {
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
}
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
+2
View File
@@ -663,6 +663,8 @@ export const subscribeCondition = (
onChange: (result: {
result?: boolean;
error?: string | { code: string; message: string };
/** Template errors while still producing a result. */
template_errors?: string[];
}) => void,
condition: Condition
) =>
+24 -9
View File
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.parentDeviceName",
weight: 6,
},
{
name: "search_labels.areaName",
weight: 6,
@@ -129,14 +133,20 @@ export const getEntities = (
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
@@ -146,7 +156,11 @@ export const getEntities = (
}
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -159,6 +173,7 @@ export const getEntities = (
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
+33 -17
View File
@@ -1,14 +1,15 @@
import type {
HassConfig,
HassEntities,
HassEntity,
HassEntityAttributeBase,
MessageBase,
} from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import type { LocalizeFunc } from "../common/translations/localize";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
@@ -336,7 +337,7 @@ const processTimelineEntity = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
config: HassConfig,
entities: HomeAssistant["entities"],
hass: HomeAssistant,
entityId: string,
states: EntityHistoryState[],
current_state: HassEntity | undefined
@@ -358,7 +359,7 @@ const processTimelineEntity = (
localize,
locale,
config,
entities[entityId],
hass.entities[entityId],
entityId,
{
...(state.a || first.a),
@@ -374,10 +375,16 @@ const processTimelineEntity = (
}
return {
name: computeStateNameFromEntityAttributes(
entityId,
current_state?.attributes || first.a
),
name: current_state
? hass.formatEntityName(current_state, DEFAULT_ENTITY_NAME, {
separator: computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ",
}) || current_state.entity_id
: computeStateNameFromEntityAttributes(entityId, first.a),
entity_id: entityId,
data,
};
@@ -387,9 +394,15 @@ const processLineChartEntities = (
unit: string,
device_class: string | undefined,
entities: HistoryStates,
hassEntities: HassEntities
hass: HomeAssistant
): LineChartUnit => {
const data: LineChartEntity[] = [];
const nameSeparator = computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
@@ -436,16 +449,19 @@ const processLineChartEntities = (
processedStates.push(processedState);
}
const attributes =
entityId in hassEntities
? hassEntities[entityId].attributes
: "friendly_name" in first.a
? first.a
: undefined;
const name =
entityId in hass.states
? hass.formatEntityName(hass.states[entityId], DEFAULT_ENTITY_NAME, {
separator: nameSeparator,
}) || entityId
: computeStateNameFromEntityAttributes(
entityId,
"friendly_name" in first.a ? first.a : {}
);
data.push({
domain,
name: computeStateNameFromEntityAttributes(entityId, attributes || {}),
name,
entity_id: entityId,
states: processedStates,
});
@@ -610,7 +626,7 @@ export const computeHistory = (
localize,
hass.locale,
hass.config,
hass.entities,
hass,
entityId,
stateInfo,
currentState
@@ -638,7 +654,7 @@ export const computeHistory = (
unit,
deviceClass,
lineChartDevices[key],
hass.states
hass
);
});
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
export interface LovelaceBadgeConfig {
type: string;
[key: string]: any;
visibility?: Condition[];
visibility?: VisibilityCondition[];
disabled?: boolean;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type {
LovelaceGridOptions,
LovelaceLayoutOptions,
@@ -13,6 +13,6 @@ export interface LovelaceCardConfig {
grid_options?: LovelaceGridOptions;
type: string;
[key: string]: any;
visibility?: Condition[];
visibility?: VisibilityCondition[];
disabled?: boolean;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { LovelaceCardConfig } from "./card";
import type { LovelaceStrategyConfig } from "./strategy";
@@ -10,7 +10,7 @@ export interface LovelaceSectionBackgroundConfig {
}
export interface LovelaceBaseSectionConfig {
visibility?: Condition[];
visibility?: VisibilityCondition[];
disabled?: boolean;
column_span?: number;
row_span?: number;
+2 -2
View File
@@ -1,5 +1,5 @@
import { titleCase } from "../../../common/string/title-case";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { MediaSelectorValue } from "../../selector";
import type { LovelaceBadgeConfig } from "./badge";
import type { LovelaceCardConfig } from "./card";
@@ -52,7 +52,7 @@ export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
content_label?: string;
sidebar_label?: string;
visibility?: Condition[];
visibility?: VisibilityCondition[];
}
export interface LovelaceBaseViewConfig {
+32
View File
@@ -0,0 +1,32 @@
import type { HomeAssistant } from "../types";
/** Transport with host and port, or with the serial device path. */
export type ModbusEndpoint = [string, string, number] | [string, string];
export interface ModbusConnection {
endpoint: ModbusEndpoint;
connected: boolean;
/** The unit IDs each config entry holds, keyed by config entry ID. */
units: Record<string, number[]>;
}
export const listModbusConnections = async (
hass: HomeAssistant
): Promise<ModbusConnection[]> =>
(
await hass.callWS<{ connections: ModbusConnection[] }>({
type: "modbus/connections/list",
})
).connections;
/** The serial port a connection runs over, if it is not a network connection. */
export const modbusSerialDevice = (
endpoint: ModbusEndpoint
): string | undefined => (endpoint[0] === "serial" ? endpoint[1] : undefined);
/** The address of the device, as `host:port` or as a device path. */
export const modbusEndpointTarget = (endpoint: ModbusEndpoint): string =>
endpoint.length === 3 ? `${endpoint[1]}:${endpoint[2]}` : endpoint[1];
export const modbusUnitCount = (connection: ModbusConnection): number =>
Object.values(connection.units).reduce((total, ids) => total + ids.length, 0);
+16
View File
@@ -22,6 +22,7 @@ import type {
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
import type { HaDurationData } from "../components/ha-duration-input";
export type ThresholdMode = "crossed" | "changed" | "is";
@@ -63,6 +64,7 @@ export type Selector =
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| OffsetSelector
| PeriodSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -446,6 +448,20 @@ export interface ObjectSelector {
} | null;
}
export type OffsetType = "none" | "before" | "after";
export interface OffsetSelector {
offset: {
enable_day?: boolean;
enable_millisecond?: boolean;
} | null;
}
export interface OffsetSelectorValue {
type: OffsetType;
duration?: HaDurationData | string | number;
}
export type PeriodKey =
| "today"
| "yesterday"
+20 -5
View File
@@ -1,8 +1,11 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
import type { OffsetSelectorValue, Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -82,10 +85,7 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const name = hass.formatEntityName(stateObj, [
{ type: "device" },
{ type: "entity" },
]);
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
return name || entityId;
})
.join(", ");
@@ -125,6 +125,21 @@ export const formatSelectorValue = (
.join(", ");
}
if ("offset" in selector) {
const { type, duration } = value as OffsetSelectorValue;
const durationData = durationValueToData(duration);
if (type === "none" || !durationData) {
return "";
}
const formattedDuration = formatDurationLong(hass.locale, durationData);
if (!formattedDuration) {
return "";
}
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
duration: formattedDuration,
});
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
@@ -0,0 +1,106 @@
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import type { HaDurationData } from "../../components/ha-duration-input";
import type { PlatformTrigger } from "../automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../automation";
import type { OffsetSelectorValue } from "../selector";
import type { TriggerDescription } from "../trigger";
const TRIGGER_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"options",
];
const isOffsetSelectorValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
const absDuration = (duration: HaDurationData): HaDurationData =>
Object.fromEntries(
Object.entries(duration).map(([field, amount]) => [
field,
Math.abs(amount ?? 0),
])
);
const migrateLegacyOffsetOptions = (
options: Record<string, unknown>,
fields: TriggerDescription["fields"]
): Record<string, unknown> => {
let migrated: Record<string, unknown> | undefined;
for (const [key, field] of Object.entries(fields)) {
if (!field.selector || !("offset" in field.selector)) {
continue;
}
const typeKey = `${key}_type`;
const value = options[key] as OffsetSelectorValue["duration"] | undefined;
const hasLegacyType = typeKey in options;
if (
!hasLegacyType &&
(value === undefined || isOffsetSelectorValue(value))
) {
continue;
}
migrated ??= { ...options };
delete migrated[typeKey];
if (isOffsetSelectorValue(value)) {
continue;
}
const duration = durationValueToData(value ?? 0);
if (!duration || Object.values(duration).some((amount) => isNaN(amount))) {
continue;
}
let seconds = durationDataToSeconds(duration);
// The released trigger schema defaulted offset_type to before
if (options[typeKey] !== "after") {
seconds = -seconds;
}
migrated[key] =
seconds === 0
? { type: "none" }
: {
type: seconds < 0 ? "before" : "after",
duration: absDuration(duration),
};
}
return migrated ?? options;
};
const moveStrayKeysToOptions = (
trigger: PlatformTrigger
): PlatformTrigger | undefined => {
let migrated: PlatformTrigger | undefined;
for (const key in trigger) {
if (TRIGGER_KEYS.includes(key as keyof PlatformTrigger)) {
continue;
}
migrated ??= { ...trigger, options: { ...trigger.options } };
migrated.options![key] = trigger[key];
delete migrated[key];
}
return migrated;
};
export const migratePlatformTrigger = (
trigger: PlatformTrigger,
description?: TriggerDescription
): PlatformTrigger | undefined => {
let migrated = moveStrayKeysToOptions(trigger);
const options = (migrated ?? trigger).options;
if (options && description?.fields) {
const migratedOptions = migrateLegacyOffsetOptions(
options,
description.fields
);
if (migratedOptions !== options) {
migrated = { ...(migrated ?? trigger), options: migratedOptions };
}
}
return migrated;
};
@@ -9,6 +9,7 @@ import { domainToName } from "../../data/integration";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
loadFlowStepTranslations,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
@@ -32,6 +33,7 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
fetchFlow: async (hass, flowId) => {
@@ -45,9 +47,14 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", step.handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: async (hass, flowId, data) => {
const step = await handleConfigFlowStep(hass, flowId, data);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: handleConfigFlowStep,
deleteFlow: deleteConfigFlow,
renderAbortDescription(hass, step) {
@@ -154,6 +154,19 @@ export interface FlowConfig {
export type LoadingReason =
"loading_handlers" | "loading_flow" | "loading_step";
/**
* Load the translations a step resolves against when it points at another
* integration, which owns strings shared between integrations.
*/
export const loadFlowStepTranslations = async (
hass: HomeAssistant,
step: DataEntryFlowStep
): Promise<void> => {
if ("translation_domain" in step && step.translation_domain) {
await hass.loadBackendTranslation("config", step.translation_domain);
}
};
export interface DataEntryFlowDialogParams {
startFlowHandler?: string;
searchQuery?: string;
@@ -34,6 +34,8 @@ export const showSubConfigFlowDialog = (
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
// Shared abort reasons live in the homeassistant integration
hass.loadBackendTranslation("config", "homeassistant"),
]);
return step;
},
@@ -46,16 +48,24 @@ export const showSubConfigFlowDialog = (
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
await hass.loadBackendTranslation("config", "homeassistant");
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
// A translation domain means the reason is shared by several integrations
// and is defined once under `config`, not per subentry type
const description = step.translation_domain
? hass.localize(
`component.${step.translation_domain}.config.abort.${step.reason}`,
step.description_placeholders
)
: hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -10,6 +11,7 @@ import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -91,8 +93,12 @@ class MoreInfoUpdate extends LitElement {
@state() private _markdownLoading = true;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
@@ -105,6 +111,8 @@ class MoreInfoUpdate extends LitElement {
// ignore error, because user will get a manual backup option
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
@@ -115,6 +123,7 @@ class MoreInfoUpdate extends LitElement {
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
this._createBackupLoading = false;
return;
}
@@ -125,6 +134,9 @@ class MoreInfoUpdate extends LitElement {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
@@ -153,6 +165,10 @@ class MoreInfoUpdate extends LitElement {
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
@@ -332,25 +348,34 @@ class MoreInfoUpdate extends LitElement {
</div>
<div class="footer">
${
createBackupTexts
createBackupTexts || this._backupConfigLoading
? html`
<ha-row-item>
<span slot="headline">${createBackupTexts.title}</span>
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
>
${
createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>
${
this._createBackupLoading
? html`<ha-fade-in
class="skeleton-end"
slot="end"
.delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`
: nothing
@@ -416,19 +441,34 @@ class MoreInfoUpdate extends LitElement {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources().then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
}
this._fetchEntitySources()
.then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
} else {
this._createBackupLoading = false;
}
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
}
});
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
} else {
this._backupConfigLoading = false;
}
})
.catch((err) => {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
this._createBackupLoading = false;
this._backupConfigLoading = false;
});
} else {
this._createBackupLoading = false;
this._backupConfigLoading = false;
}
}
@@ -587,6 +627,11 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding-bottom: var(--ha-space-4);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
+9 -3
View File
@@ -591,11 +591,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
+16 -1
View File
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
? (domain as ItemType)
: "entity";
const { floor, area, device } = getEntityContext(
const { floor, area, device, parentDevice } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
@@ -91,6 +91,13 @@ class HaMoreInfoRelated extends LitElement {
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states
)
: undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
@@ -124,6 +131,14 @@ class HaMoreInfoRelated extends LitElement {
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (parentDevice && parentDeviceName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.parent_device",
value: parentDeviceName,
href: `/config/devices/device/${parentDevice.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
+107 -72
View File
@@ -1,13 +1,16 @@
import { consume } from "@lit/context";
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { setupConditionListeners } from "../common/condition/listeners";
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
import { maxColumnsContext } from "../panels/lovelace/common/context";
import { evaluateConditionsLocally } from "../common/condition/evaluate-locally";
import type {
Condition,
ConditionContext,
VisibilityCondition,
} from "../panels/lovelace/common/validate-condition";
import { addEntityToCondition } from "../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../types";
type Constructor<T> = abstract new (...args: any[]) => T;
@@ -15,27 +18,19 @@ type Constructor<T> = abstract new (...args: any[]) => T;
* Base config type that can be used with conditional listeners
*/
export interface ConditionalConfig {
visibility?: Condition[];
visibility?: VisibilityCondition[];
[key: string]: any;
}
/**
* Mixin to handle conditional listeners for visibility control
* Mixin for dashboard visibility.
*
* Provides lifecycle management for listeners that control conditional
* visibility of components.
* Stateful conditions go to core via `subscribe_condition`; screen/user/time
* stay local. Call `_conditionsVisible()` from `_updateVisibility` /
* `_updateElement`.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
* 2. Ensure component has config.visibility or _config.visibility property with conditions
* 3. Ensure component has _updateVisibility() or _updateElement() method
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
* - Handles conditional visibility based on defined conditions
* - Consumes column count from the view via Lit Context
* Override `setupConditionalListeners()` to pass a custom list (e.g. a
* conditional card's `conditions`).
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
@@ -43,8 +38,6 @@ export const ConditionalListenerMixin = <
superClass: Constructor<ReactiveElement>
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
@@ -57,6 +50,40 @@ export const ConditionalListenerMixin = <
protected _conditionContext: ConditionContext = {};
// What the evaluator is currently watching; used for the local seed.
private __conditions?: VisibilityCondition[];
// `unknown` until a server subtree reports (or immediately if all client).
private __conditionResult: ConditionEvaluation = "unknown";
// Folded conditions, rebuilt only when the source or entity id changes.
private __observedSource?: VisibilityCondition[];
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
// Drop the cached verdict when the tree content changes.
private __conditionsSignature?: string;
private __conditionEvaluator = new ConditionEvaluatorController(this, {
// Local seed covers the first frame; no need to debounce.
resubscribeDelay: 0,
onResult: (result) => {
this.__conditionResult = result;
// We set visibility ourselves; ignore the disconnect `unknown`.
if (!this.isConnected) {
return;
}
const config = this._config || this.config;
if (this._updateVisibility) {
this._updateVisibility();
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
});
protected _updateElement?(config: TConfig): void;
protected _updateVisibility?(conditionsMet?: boolean): void;
@@ -66,11 +93,6 @@ export const ConditionalListenerMixin = <
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
@@ -83,67 +105,80 @@ export const ConditionalListenerMixin = <
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("_maxColumns")) {
this._updateVisibility?.();
// After willUpdate so consumers can set `_conditionContext.entity_id`.
if (
changedProperties.has("hass") ||
changedProperties.has("config") ||
changedProperties.has("_config") ||
changedProperties.has("_maxColumns")
) {
this.setupConditionalListeners();
}
}
/**
* Clear conditional listeners
*
* This method is called when the component is disconnected from the DOM.
* It clears all the listeners that were set up by the setupConditionalListeners() method.
* True if the observed conditions currently pass.
* Uses the server result when known; otherwise a local seed that stays
* unknown (treated as hidden) for anything only core can evaluate.
*/
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
protected _conditionsVisible(): boolean {
const conditions = this.__conditions;
if (!conditions || conditions.length === 0) {
return true;
}
if (this.__conditionResult !== "unknown") {
return this.__conditionResult === "visible";
}
if (!this.hass) {
return true;
}
return (
evaluateConditionsLocally(
conditions,
this.hass,
this._conditionContext
) === true
);
}
/**
* Add a conditional listener to the list of listeners
*
* This method is called when a new listener is added.
* It adds the listener to the list of listeners.
*
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
* @returns void
* Pass conditions to the evaluator.
* Override to supply a custom list, then call `super.setupConditionalListeners(...)`.
*/
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
protected setupConditionalListeners(
conditions?: VisibilityCondition[]
): void {
// Prefer resolved `_config` (strategy sections) over the raw `config`.
const config = this._config || this.config;
const finalConditions = conditions ?? config?.visibility;
const entityId = this._conditionContext.entity_id;
/**
* Setup conditional listeners for visibility control
*
* Default implementation:
* - Checks config.visibility or _config.visibility for conditions (if not provided)
* - Sets up appropriate listeners based on condition types
* - Calls _updateVisibility() or _updateElement() when conditions change
*
* Override this method to customize behavior (e.g., filter conditions first)
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
*
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
*/
protected setupConditionalListeners(conditions?: Condition[]): void {
const config = this.config || this._config;
const finalConditions = conditions || config?.visibility;
this.__conditions = finalConditions;
if (!finalConditions || !this.hass) {
return;
// Fold in the host entity and keep a stable array across hass updates.
if (
finalConditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
// Tree content changed; don't keep the previous result for a frame.
const signature = finalConditions
? JSON.stringify(finalConditions)
: undefined;
if (signature !== this.__conditionsSignature) {
this.__conditionsSignature = signature;
this.__conditionResult = "unknown";
}
this.__observedSource = finalConditions;
this.__observedEntityId = entityId;
this.__observed =
finalConditions && entityId
? finalConditions.map((c) => addEntityToCondition(c, entityId))
: finalConditions;
}
setupConditionListeners(
finalConditions,
this.__conditionEvaluator.observe(
this.__observed,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
() => this._conditionContext
);
}
+1
View File
@@ -267,6 +267,7 @@ class OnboardingLocation extends LitElement {
? location[1]
: Number(place.lon),
location_editable: place.place_id === highlightedMarker,
clickable: true,
}))
: [];
}
@@ -1,12 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAppleKeyboardCommand,
mdiClose,
mdiContentPaste,
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import { mdiClose, mdiHelpCircleOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -19,9 +13,11 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { isNumericState } from "../../../common/number/format_number";
import { stringCompare } from "../../../common/string/compare";
import type {
@@ -122,8 +118,8 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import "./add-automation-element/ha-automation-add-element-paste";
import "./add-automation-element/ha-automation-add-from-target";
import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
@@ -724,6 +720,9 @@ class DialogAddAutomationElement
(this._narrow && !!this._selectedGroup),
})}
.manifests=${this._manifests}
.clipboardItem=${this._params!.clipboardItem}
.automationElementType=${automationElementType}
@paste-element=${this._paste}
></ha-automation-add-from-target>`
: html`
<ha-list-base
@@ -733,56 +732,12 @@ class DialogAddAutomationElement
"ha-scrollbar": true,
})}
>
${
this._params!.clipboardItem
? html`<ha-list-item-button
class="paste"
@click=${this._paste}
>
<div slot="headline" class="label">
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
${
!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${
isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)
}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing
}
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon
><ha-svg-icon
class="plus"
slot="end"
.path=${mdiPlus}
></ha-svg-icon>
</ha-list-item-button>
<wa-divider></wa-divider>`
: nothing
}
<ha-automation-add-element-paste
.automationElementType=${automationElementType}
.clipboardItem=${this._params!.clipboardItem}
@paste-element=${this._paste}
divider
></ha-automation-add-element-paste>
${collections.map(
(collection) => html`
${
@@ -948,6 +903,12 @@ class DialogAddAutomationElement
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
this._selectedTarget
);
const separator = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
if (targetId) {
if (targetType === "area") {
@@ -960,11 +921,21 @@ class DialogAddAutomationElement
);
}
} else if (targetType === "device") {
const areaId = this.hass.devices[targetId]?.area_id;
if (areaId) {
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
const device = this.hass.devices[targetId];
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(separator);
} else {
const device = this.hass.devices[targetId];
subtitle = this.hass.localize(
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
);
@@ -980,25 +951,28 @@ class DialogAddAutomationElement
);
} else {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(separator);
}
}
@@ -2447,17 +2421,18 @@ class DialogAddAutomationElement
border-radius: var(--ha-border-radius-xl);
border: 1px solid var(--ha-color-border-neutral-quiet);
margin: var(--ha-space-3);
}
ha-automation-add-from-target,
.groups {
overflow: auto;
/* Fixed-width left column so it does not resize as the right
panel's content width changes between groups. */
flex: 0 0 360px;
margin-inline-end: 0;
}
@media (max-width: 870px), (max-height: 500px) {
ha-automation-add-from-target,
.groups {
flex: 1 1 auto;
}
}
ha-automation-add-from-target.hidden {
display: none;
}
@@ -2526,29 +2501,10 @@ class DialogAddAutomationElement
width: var(--ha-space-6);
}
wa-divider {
--spacing: 0;
}
ha-svg-icon.plus {
color: var(--primary-color);
}
.shortcut {
direction: ltr;
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
}
.section-title-wrapper {
height: 0;
position: relative;
@@ -0,0 +1,112 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiAppleKeyboardCommand, mdiContentPaste, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import {
internationalizationContext,
narrowViewportContext,
} from "../../../../data/context";
import { isMac } from "../../../../util/is_mac";
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
import { shortcutStyles } from "../styles";
@customElement("ha-automation-add-element-paste")
export class HaAutomationAddElementPaste extends LitElement {
@property({ attribute: "clipboard-item" }) public clipboardItem?;
@property({ attribute: "automation-element-type" })
public automationElementType!: AddAutomationElementDialogParams["type"];
@property({ type: Boolean }) public divider = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
protected _narrow!: ContextType<typeof narrowViewportContext>;
protected render() {
if (!this.clipboardItem) {
return nothing;
}
return html`<ha-list-item-button class="paste" @click=${this._paste}>
<div slot="headline" class="label">
${this._i18n.localize(
`ui.panel.config.automation.editor.${this.automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this._i18n.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${this.automationElementType}s.type.${this.clipboardItem}.label`
)}
</div>
${
!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${
isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this._i18n.localize(
"ui.panel.config.automation.editor.ctrl"
)
}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing
}
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon
><ha-svg-icon class="plus" slot="end" .path=${mdiPlus}></ha-svg-icon>
</ha-list-item-button>
${this.divider ? html`<wa-divider></wa-divider>` : nothing}`;
}
private _paste() {
fireEvent(this, "paste-element");
}
static styles = [
shortcutStyles,
css`
:host {
display: block;
}
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-padding-inline: var(--ha-space-3);
--ha-row-item-min-height: 40px;
}
wa-divider {
--spacing: 0;
}
ha-svg-icon.plus {
color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-element-paste": HaAutomationAddElementPaste;
}
interface HASSDomEvents {
"paste-element": undefined;
}
}
@@ -65,6 +65,8 @@ import {
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
import "./ha-automation-add-element-paste";
interface Level1Entries {
open: boolean;
@@ -105,6 +107,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public selectedGroup?: string;
@property({ attribute: false }) public clipboardItem?: string;
@property({ attribute: "automation-element-type" })
public automationElementType!: AddAutomationElementDialogParams["type"];
// #endregion properties
// #region context
@@ -193,6 +200,12 @@ export default class HaAutomationAddFromTarget extends LitElement {
this.narrow && this.value
? this._renderNarrow(this._entries, this.value)
: html`
<ha-list-base>
<ha-automation-add-element-paste
.automationElementType=${this.automationElementType}
.clipboardItem=${this.clipboardItem}
></ha-automation-add-element-paste>
</ha-list-base>
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
@@ -344,28 +357,32 @@ export default class HaAutomationAddFromTarget extends LitElement {
);
});
return html`<ha-section-title
>${this._i18n.localize(
"ui.panel.config.automation.editor.home"
)}</ha-section-title
>
${
emptyFloors
? html`<ha-row-item>
<div slot="headline">
${this._i18n.localize("ui.components.area-picker.no_areas")}
</div>
</ha-row-item>`
: html`${
narrow
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
}`;
return html`${
!narrow || (this._floorAreas.length >= 1 && this._floorAreas[0].id)
? html`<ha-section-title
>${this._i18n.localize(
"ui.panel.config.automation.editor.home"
)}</ha-section-title
>`
: nothing
}
${
emptyFloors
? html`<ha-row-item>
<div slot="headline">
${this._i18n.localize("ui.components.area-picker.no_areas")}
</div>
</ha-row-item>`
: html`${
narrow
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
}`;
}
);
@@ -5,7 +5,6 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiCommentEditOutline,
mdiCommentTextOutline,
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
@@ -19,7 +18,6 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -32,10 +30,10 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-row-event-chip";
@@ -64,7 +62,6 @@ import {
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
showAlertDialog,
showPromptDialog,
@@ -73,12 +70,7 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
@@ -181,26 +173,6 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
return html`
${
this.optionsInSidebar && this.condition.condition !== "trigger"
@@ -226,56 +198,15 @@ export default class HaAutomationConditionRow extends LitElement {
></ha-condition-icon>
</div>`
}
<h3 slot="header">
${capitalizeFirstLetter(
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
target,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing
}
</h3>
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
.isNew=${this._isNew}
></ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -621,45 +552,6 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
@@ -7,8 +7,9 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import { afterNextRender } from "../../../../../common/util/render-status";
import "../../../../../components/ha-checkbox";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
@@ -163,7 +164,8 @@ export class HaPlatformCondition extends LitElement {
}
if (oldValue?.target !== this.condition?.target) {
this._updateResolvedTargetEntityCount(this.condition?.target);
this._updateTargetEntityCount();
this._setDefaultBehavior();
}
}
@@ -476,41 +478,51 @@ export class HaPlatformCondition extends LitElement {
}
}
private _updateResolvedTargetEntityCount(
target: PlatformCondition["target"]
) {
private _updateTargetEntityCount() {
const target = this.condition?.target;
this._resolvedTargetEntityCount = getTargetEntityCount(target);
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (!behaviorFieldEntry) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
target &&
this._resolvedTargetEntityCount > 1 &&
this.condition.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options: {
...this.condition.options,
[behaviorFieldName]: behaviorDefault,
},
},
});
private _setDefaultBehavior() {
// set default behavior after next render to prevent race conditions with the initial render
afterNextRender(() => {
if (!this.isConnected) {
return;
}
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (
!behaviorFieldEntry ||
this._resolvedTargetEntityCount === undefined
) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
this.condition?.target &&
this._resolvedTargetEntityCount > 1 &&
this.condition.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options: {
...this.condition.options,
[behaviorFieldName]: behaviorDefault,
},
},
});
}
}
});
}
// Shows a small info icon beside the `for` duration field's label, with a
+17
View File
@@ -313,3 +313,20 @@ export const overflowStyles = css`
}
}
`;
export const shortcutStyles = css`
.shortcut {
direction: ltr;
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
}
`;
@@ -4,12 +4,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import { afterNextRender } from "../../../../../common/util/render-status";
import "../../../../../components/ha-checkbox";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -19,6 +19,7 @@ import {
getTriggerObjectId,
type TriggerDescription,
} from "../../../../../data/trigger";
import { migratePlatformTrigger } from "../../../../../data/trigger/migrate_platform_trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
@@ -27,13 +28,6 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!field.required &&
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"options",
];
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,34 +54,21 @@ export class HaPlatformTrigger extends LitElement {
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("selector");
}
if (
changedProperties.has("trigger") ||
changedProperties.has("description")
) {
const migrated = migratePlatformTrigger(this.trigger, this.description);
if (migrated) {
fireEvent(this, "value-changed", { value: migrated });
this.trigger = migrated;
}
}
if (!changedProperties.has("trigger")) {
return;
}
let newValue: PlatformTrigger | undefined;
for (const key in this.trigger) {
// Migrate old options to `options`
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
continue;
}
if (newValue === undefined) {
newValue = {
...this.trigger,
options: { [key]: this.trigger[key] },
};
} else {
newValue.options![key] = this.trigger[key];
}
delete newValue[key];
}
if (newValue !== undefined) {
fireEvent(this, "value-changed", {
value: newValue,
});
this.trigger = newValue;
}
const oldValue = changedProperties.get("trigger") as
undefined | this["trigger"];
@@ -155,7 +136,8 @@ export class HaPlatformTrigger extends LitElement {
}
if (oldValue?.target !== this.trigger?.target) {
this._updateResolvedTargetEntityCount(this.trigger?.target);
this._updateTargetEntityCount();
this._setDefaultBehavior();
}
}
@@ -466,39 +448,51 @@ export class HaPlatformTrigger extends LitElement {
}
}
private _updateResolvedTargetEntityCount(target: PlatformTrigger["target"]) {
private _updateTargetEntityCount() {
const target = this.trigger?.target;
this._resolvedTargetEntityCount = getTargetEntityCount(target);
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (!behaviorFieldEntry) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
target &&
this._resolvedTargetEntityCount > 1 &&
this.trigger.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options: {
...this.trigger.options,
[behaviorFieldName]: behaviorDefault,
},
},
});
private _setDefaultBehavior() {
// set default behavior after next render to prevent race conditions with the initial render
afterNextRender(() => {
if (!this.isConnected) {
return;
}
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (
!behaviorFieldEntry ||
this._resolvedTargetEntityCount === undefined
) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
this.trigger?.target &&
this._resolvedTargetEntityCount > 1 &&
this.trigger.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options: {
...this.trigger.options,
[behaviorFieldName]: behaviorDefault,
},
},
});
}
}
});
}
static styles = css`
@@ -148,7 +148,7 @@ class DialogRestoreBackup
<ha-dialog
.open=${this._open}
header-title=${dialogTitle}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div class="content">
+9
View File
@@ -33,6 +33,7 @@ import {
mdiStarFourPoints,
mdiTextBoxOutline,
mdiTools,
mdiTransitConnectionVariant,
mdiUpdate,
mdiViewDashboard,
mdiZigbee,
@@ -188,6 +189,14 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "serial",
adminOnly: true,
},
{
path: "/config/modbus",
iconPath: mdiTransitConnectionVariant,
iconColor: "#00897B",
component: "modbus",
translationKey: "modbus",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "id", weight: 2 },
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
return html`
<ha-dialog
File diff suppressed because it is too large Load Diff
@@ -1,903 +0,0 @@
import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCancel,
mdiEyeOff,
mdiPencilOff,
mdiRestoreAlert,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { guard } from "lit/directives/guard";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import {
computeDeviceName,
getDuplicatedDeviceNames,
} from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import type {
DataTableColumnContainer,
RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-alert";
import "../../../components/ha-entity-id-icon";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-states";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { CloudStatus } from "../../../data/cloud";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries } from "../../../data/config_entries";
import {
areasContext,
configEntriesContext,
devicesContext,
fullEntitiesContext,
internationalizationContext,
labelsContext,
statesContext,
} from "../../../data/context";
import type {
DataTableFilter,
DataTableFiltersItems,
DataTableFiltersValues,
} from "../../../data/data_table_filters";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { ExposeEntitySettings } from "../../../data/expose";
import { listExposedEntities, voiceAssistants } from "../../../data/expose";
import { domainToName } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import "../../../layouts/hass-loading-screen";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import type {
HomeAssistant,
HomeAssistantInternationalization,
Route,
} from "../../../types";
import {
getAreaTableColumn,
getCreatedAtTableColumn,
getDomainTableColumn,
getEntityIdTableColumn,
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import {
getAssistantsSortableKey,
getAssistantsTableColumn,
} from "../voice-assistants/expose/assistants-table-column";
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
export interface StateEntity extends Omit<
EntityRegistryEntry,
"id" | "unique_id"
> {
readonly?: boolean;
selectable?: boolean;
id?: string;
unique_id?: string;
}
export interface EntityRow extends StateEntity {
unavailable: boolean;
restored: boolean;
status: string | undefined;
area?: string;
device?: string;
device_full?: string;
localized_platform: string;
domain: string;
label_entries: LabelRegistryEntry[];
assistants: string[];
assistants_sortable_key: string | undefined;
enabled: string;
visible: string;
available: string;
}
export interface EntityTableFilterContext {
filteredDomains: ReadonlySet<string>;
filteredConfigEntry: ConfigEntry | undefined;
}
@customElement("ha-entity-table")
export class HaEntityTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public tabs: PageNavigation[] = [];
@property({ attribute: "back-path" }) public backPath?: string;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property() public filter = "";
@property({ attribute: false }) public filterValues: DataTableFiltersValues =
{};
@property({ type: Boolean }) public selectable = false;
@property({ type: Boolean }) public clickable = false;
@property({ type: Number }) public selected = 0;
@property({ attribute: "has-fab", type: Boolean }) public hasFab = false;
@property({ attribute: false }) public initialSorting?: SortingChangedEvent;
@property({ attribute: false }) public initialGroupColumn?: string;
@property({ attribute: false }) public initialCollapsedGroups?: string[];
@property({ attribute: false }) public columnOrder?: string[];
@property({ attribute: false }) public hiddenColumns?: string[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entities?: EntityRegistryEntry[];
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates!: HomeAssistant["states"];
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: HomeAssistant["devices"];
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: HomeAssistant["areas"];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labels?: LabelRegistryEntry[];
@state()
@consume({ context: configEntriesContext, subscribe: true })
private _entries?: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() private _entitySources?: EntitySources;
@state() private _exposedEntities?: Record<string, ExposeEntitySettings>;
@state() private _filteredItems: DataTableFiltersItems = {};
@state() private _expandedFilter?: string;
private _itemFilterValues: DataTableFiltersValues = {};
private _subEntryConfigEntry?: string;
private _filterContext?: EntityTableFilterContext;
@query("hass-tabs-subpage-data-table")
private _dataTable?: HaTabsSubpageDataTable;
public connectedCallback() {
super.connectedCallback();
window.addEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
);
}
public clearSelection() {
this._dataTable?.clearSelection();
}
protected firstUpdated() {
this._fetchExposedEntities();
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("filterValues")) {
this._filteredItems = Object.fromEntries(
Object.entries(this._filteredItems).filter(
([key]) =>
this.filterValues[key] !== undefined &&
deepEqual(this.filterValues[key], this._itemFilterValues[key])
)
);
const configEntry =
Array.isArray(this.filterValues.config_entry) &&
this.filterValues.config_entry.length === 1
? this.filterValues.config_entry[0]
: undefined;
const subEntryConfigEntry =
Array.isArray(this.filterValues.sub_entry) &&
this.filterValues.sub_entry.length
? configEntry
: undefined;
if (subEntryConfigEntry !== this._subEntryConfigEntry) {
this._subEntryConfigEntry = subEntryConfigEntry;
this._subEntries = undefined;
if (subEntryConfigEntry) {
this._loadSubEntries(subEntryConfigEntry);
}
}
}
}
protected updated() {
if (!this._entities) {
return;
}
const { filteredDomains, filteredConfigEntry } = this._getRows();
if (
this._filterContext &&
this._filterContext.filteredConfigEntry === filteredConfigEntry &&
this._filterContext.filteredDomains.size === filteredDomains.size &&
[...filteredDomains].every((domain) =>
this._filterContext!.filteredDomains.has(domain)
)
) {
return;
}
this._filterContext = { filteredDomains, filteredConfigEntry };
fireEvent(this, "entity-table-filter-context-changed", this._filterContext);
}
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
label: localize("ui.panel.config.entities.picker.status.available"),
},
{
value: "unavailable",
label: localize("ui.panel.config.entities.picker.status.unavailable"),
},
{
value: "enabled",
label: localize("ui.panel.config.entities.picker.status.enabled"),
},
{
value: "disabled",
label: localize("ui.panel.config.entities.picker.status.disabled"),
},
{
value: "visible",
label: localize("ui.panel.config.entities.picker.status.visible"),
},
{
value: "hidden",
label: localize("ui.panel.config.entities.picker.status.hidden"),
},
{
value: "readonly",
label: localize("ui.panel.config.entities.picker.status.unmanageable"),
},
{
value: "restored",
label: localize("ui.panel.config.entities.picker.status.not_provided"),
},
]);
private _columns = memoize(
(
localize: LocalizeFunc,
hass: HomeAssistant,
availableAssistants: string[],
entitiesToCheck: EntityRow[]
): DataTableColumnContainer<EntityRow> => ({
icon: {
title: "",
label: localize("ui.panel.config.entities.picker.headers.state_icon"),
type: "icon",
showNarrow: true,
moveable: false,
template: (entry) =>
entry.icon
? html`<ha-icon .icon=${entry.icon}></ha-icon>`
: html`<ha-entity-id-icon
state-title
slot="item-icon"
.entityId=${entry.entity_id}
></ha-entity-id-icon>`,
},
name: {
main: true,
title: localize("ui.panel.config.entities.picker.headers.entity"),
sortable: true,
filterable: true,
direction: "asc",
extraTemplate: (entry) =>
entry.label_entries.length
? html`<ha-data-table-labels
.labels=${entry.label_entries}
></ha-data-table-labels>`
: nothing,
},
device: {
title: localize("ui.panel.config.entities.picker.headers.device"),
sortable: true,
template: (entry) => entry.device || "\u2014",
},
device_full: {
title: localize("ui.panel.config.entities.picker.headers.device"),
filterable: true,
groupable: true,
hidden: true,
},
area: getAreaTableColumn(localize),
entity_id: getEntityIdTableColumn(localize, true),
localized_platform: {
title: localize("ui.panel.config.entities.picker.headers.integration"),
sortable: true,
groupable: true,
filterable: true,
},
domain: getDomainTableColumn(localize),
disabled_by: {
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
hidden: true,
filterable: true,
template: (entry) =>
entry.disabled_by === null
? ""
: localize(`config_entry.disabled_by.${entry.disabled_by}`),
},
status: {
title: localize("ui.panel.config.entities.picker.headers.status"),
type: "icon",
showNarrow: true,
sortable: true,
filterable: true,
minWidth: "80px",
maxWidth: "80px",
template: (entry) =>
entry.unavailable ||
entry.disabled_by ||
entry.hidden_by ||
entry.readonly
? html`<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="status-icon-${slugify(entry.entity_id)}"
style=${styleMap({ color: entry.unavailable ? "var(--error-color)" : "" })}
.path=${entry.restored ? mdiRestoreAlert : entry.unavailable ? mdiAlertCircle : entry.disabled_by ? mdiCancel : entry.hidden_by ? mdiEyeOff : mdiPencilOff}
></ha-svg-icon>
<ha-tooltip
.for="status-icon-${slugify(entry.entity_id)}"
placement="left"
>
${
entry.restored
? localize(
"ui.panel.config.entities.picker.status.not_provided"
)
: entry.unavailable
? localize(
"ui.panel.config.entities.picker.status.unavailable"
)
: entry.disabled_by
? localize(
"ui.panel.config.entities.picker.status.disabled"
)
: entry.hidden_by
? localize(
"ui.panel.config.entities.picker.status.hidden"
)
: localize(
"ui.panel.config.entities.picker.status.unmanageable"
)
}
</ha-tooltip>
</div>`
: "\u2014",
},
created_at: getCreatedAtTableColumn(localize, hass),
modified_at: getModifiedAtTableColumn(localize, hass),
available: {
title: localize("ui.panel.config.entities.picker.headers.availability"),
sortable: true,
groupable: true,
hidden: true,
},
visible: {
title: localize("ui.panel.config.entities.picker.headers.visibility"),
sortable: true,
groupable: true,
hidden: true,
},
enabled: {
title: localize("ui.panel.config.entities.picker.headers.enabled"),
sortable: true,
groupable: true,
hidden: true,
},
labels: getLabelsTableColumn(),
assistants: getAssistantsTableColumn(
localize,
hass,
availableAssistants,
entitiesToCheck
),
})
);
private _stateEntities = memoize(
(
states: HomeAssistant["states"],
entities: EntityRegistryEntry[],
sources: EntitySources | undefined,
exposedEntities: Record<string, ExposeEntitySettings> | undefined
): StateEntity[] => {
const regEntityIds = new Set(entities.map((entity) => entity.entity_id));
return Object.keys(states)
.filter((entityId) => !regEntityIds.has(entityId))
.map((entityId) => ({
name: computeStateName(states[entityId]),
entity_id: entityId,
platform: sources?.[entityId]?.domain || computeDomain(entityId),
disabled_by: null,
hidden_by: null,
area_id: null,
config_entry_id: null,
config_subentry_id: null,
device_id: null,
icon: null,
readonly: true,
selectable: false,
entity_category: null,
has_entity_name: false,
options: Object.fromEntries(
Object.keys(voiceAssistants).map((vaId) => [
vaId,
{ should_expose: exposedEntities?.[entityId]?.[vaId] },
])
),
labels: [],
categories: {},
created_at: 0,
modified_at: 0,
}));
}
);
private _filteredEntitiesAndDomains = memoize(
(
localize: LocalizeFunc,
entities: StateEntity[],
states: HomeAssistant["states"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
entitiesWithoutUniqueId: StateEntity[],
filters: DataTableFiltersValues,
filteredItems: DataTableFiltersItems,
entries: ConfigEntry[] | undefined,
labelReg: LabelRegistryEntry[] | undefined,
entitySources: EntitySources | undefined,
exposedEntities: Record<string, ExposeEntitySettings> | undefined
) => {
const result: EntityRow[] = [];
const stateFilters = filters["ha-filter-states"] as string[];
const showEnabled =
!stateFilters?.length || stateFilters.includes("enabled");
const showDisabled =
!stateFilters?.length || stateFilters.includes("disabled");
const showVisible =
!stateFilters?.length || stateFilters.includes("visible");
const showHidden =
!stateFilters?.length || stateFilters.includes("hidden");
const showAvailable =
!stateFilters?.length || stateFilters.includes("available");
const showUnavailable =
!stateFilters?.length || stateFilters.includes("unavailable");
const showRestored =
!stateFilters?.length || stateFilters.includes("restored");
const showReadOnly =
!stateFilters?.length || stateFilters.includes("readonly");
let filteredEntities = entities.concat(entitiesWithoutUniqueId);
let filteredConfigEntry: ConfigEntry | undefined;
const filteredDomains = new Set<string>();
Object.entries(filters).forEach(([key, filter]) => {
if (!Array.isArray(filter) || !filter.length) {
return;
}
if (key === "config_entry") {
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_entry_id && filter.includes(entity.config_entry_id)
);
const configEntries = entries?.filter((entry) =>
filter.includes(entry.entry_id)
);
configEntries?.forEach((entry) => filteredDomains.add(entry.domain));
if (configEntries?.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (key === "sub_entry") {
if (
!Array.isArray(filters.config_entry) ||
filters.config_entry.length !== 1
) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
filter.includes(entity.config_subentry_id)
);
} else if (key === "ha-filter-integrations") {
const entryIds =
entries
?.filter((entry) => filter.includes(entry.domain))
.map((entry) => entry.entry_id) || [];
filteredEntities = filteredEntities.filter(
(entity) =>
filter.includes(
entitySources?.[entity.entity_id]?.domain ?? ""
) ||
filter.includes(entity.platform) ||
(entity.config_entry_id &&
entryIds.includes(entity.config_entry_id))
);
filter.forEach((domain) => filteredDomains.add(domain));
} else if (key === "ha-filter-domains") {
filteredEntities = filteredEntities.filter((entity) =>
filter.includes(computeDomain(entity.entity_id))
);
} else if (key === "ha-filter-labels") {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => filter.includes(lbl))
);
} else if (key === "ha-filter-voice-assistants") {
filteredEntities = filteredEntities.filter((entity) =>
filter.some((va) => exposedEntities?.[entity.entity_id]?.[va])
);
}
});
Object.values(filteredItems).forEach((items) => {
if (items) {
filteredEntities = filteredEntities.filter((entity) =>
items.has(entity.entity_id)
);
}
});
const duplicatedDevicesNames = getDuplicatedDeviceNames(devices);
for (const entry of filteredEntities) {
const entity = states[entry.entity_id];
const unavailable = entity?.state === UNAVAILABLE;
const restored = entity?.attributes.restored === true;
const device = entry.device_id ? devices[entry.device_id] : undefined;
const areaId = entry.area_id || device?.area_id;
const area = areaId ? areas[areaId] : undefined;
const hidden = !!entry.hidden_by;
const disabled = !!entry.disabled_by;
const readonly = entry.readonly;
const available = entity?.state && entity.state !== UNAVAILABLE;
if (!(
(showAvailable && available) ||
(showUnavailable && unavailable) ||
(showRestored && restored) ||
(showVisible && !hidden) ||
(showHidden && hidden) ||
(showDisabled && disabled) ||
(showEnabled && !disabled) ||
(showReadOnly && readonly)
)) {
continue;
}
const labelsEntries = (entry.labels || [])
.map((lbl) => labelReg?.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
devices,
entity
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const deviceFullName = deviceName
? duplicatedDevicesNames.has(deviceName) && areaName
? `${deviceName} (${areaName})`
: deviceName
: undefined;
const assistants = Object.keys(voiceAssistants).filter(
(va) => exposedEntities?.[entry.entity_id]?.[va]
);
result.push({
...entry,
name: entityName || deviceName || entry.entity_id,
device: deviceName,
area: areaName,
device_full: deviceFullName,
unavailable,
restored,
localized_platform: domainToName(localize, entry.platform),
domain: domainToName(localize, computeDomain(entry.entity_id)),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
status: restored
? localize("ui.panel.config.entities.picker.status.not_provided")
: unavailable
? localize("ui.panel.config.entities.picker.status.unavailable")
: disabled
? localize("ui.panel.config.entities.picker.status.disabled")
: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: readonly
? localize(
"ui.panel.config.entities.picker.status.unmanageable"
)
: localize(
"ui.panel.config.entities.picker.status.available"
),
label_entries: labelsEntries,
available: unavailable
? localize("ui.panel.config.entities.picker.status.unavailable")
: localize("ui.panel.config.entities.picker.status.available"),
enabled: disabled
? localize("ui.panel.config.entities.picker.status.disabled")
: localize("ui.panel.config.entities.picker.status.enabled"),
visible: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: localize("ui.panel.config.entities.picker.status.visible"),
});
}
return { filteredEntities: result, filteredConfigEntry, filteredDomains };
}
);
private _getRows() {
return this._filteredEntitiesAndDomains(
this._i18n.localize,
this._entities!,
this._entityStates,
this._devices,
this._areas,
this._stateEntities(
this._entityStates,
this._entities!,
this._entitySources,
this._exposedEntities
),
this.filterValues,
this._filteredItems,
this._entries,
this._labels,
this._entitySources,
this._exposedEntities
);
}
protected render() {
if (!this.hass || !this._entities) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const { filteredEntities } = this._getRows();
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this.backPath}
.route=${this.route}
.tabs=${this.tabs}
.columns=${this._columns(this._i18n.localize, this.hass, getAvailableAssistants(this.cloudStatus, this.hass), filteredEntities)}
.data=${filteredEntities}
.searchLabel=${this._i18n.localize("ui.panel.config.entities.picker.search", { number: filteredEntities.length })}
has-filters
.filters=${Object.values(this.filterValues).filter((filter) => (Array.isArray(filter) ? filter.length : filter && Object.values(filter).some((val) => (Array.isArray(val) ? val.length : val)))).length}
.selectable=${this.selectable}
.selected=${this.selected}
.initialGroupColumn=${this.initialGroupColumn}
.initialCollapsedGroups=${this.initialCollapsedGroups}
.initialSorting=${this.initialSorting}
.columnOrder=${this.columnOrder}
.hiddenColumns=${this.hiddenColumns}
.clickable=${this.clickable}
@clear-filter=${this._clearFilter}
.filter=${this.filter}
@row-click=${this._rowClicked}
id="entity_id"
.hasFab=${this.hasFab}
class=${this.narrow ? "narrow" : ""}
>
<slot name="toolbar-icon" slot="toolbar-icon"></slot>
<slot name="selection-bar" slot="selection-bar"></slot>
<slot name="top-header" slot="top-header"></slot>
<slot name="fab" slot="fab"></slot>
${
Array.isArray(this.filterValues.config_entry) &&
this.filterValues.config_entry.length
? html`<ha-alert slot="filter-pane">
${this._i18n.localize("ui.panel.config.entities.picker.filtering_by_config_entry")}
${this._entries?.find((entry) => entry.entry_id === this.filterValues.config_entry![0])?.title || this.filterValues.config_entry[0]}${
this.filterValues.config_entry.length === 1 &&
Array.isArray(this.filterValues.sub_entry) &&
this.filterValues.sub_entry.length
? html` (${this._subEntries?.find((entry) => entry.subentry_id === this.filterValues.sub_entry![0])?.title || this.filterValues.sub_entry[0]})`
: nothing
}
</ha-alert>`
: nothing
}
<ha-filter-floor-areas
type="entity"
.value=${guard(
[this.filterValues["ha-filter-floor-areas"]],
() => this.filterValues["ha-filter-floor-areas"]
)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-floor-areas"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
type="entity"
.value=${guard(
[this.filterValues["ha-filter-devices"]],
() => this.filterValues["ha-filter-devices"]
)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-devices"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-domains
.value=${this.filterValues["ha-filter-domains"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-domains"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-domains>
<ha-filter-integrations
.value=${this.filterValues["ha-filter-integrations"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-integrations"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.label=${this._i18n.localize("ui.panel.config.entities.picker.headers.status")}
.value=${this.filterValues["ha-filter-states"]}
.states=${this._states(this._i18n.localize)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-states"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-states>
<ha-filter-labels
.value=${this.filterValues["ha-filter-labels"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-labels"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-voice-assistants
.value=${this.filterValues["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
</hass-tabs-subpage-data-table>
`;
}
private _filterExpanded(
ev: HASSDomEvent<{ expanded: boolean }> &
HASSDomCurrentTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.currentTarget.localName;
} else if (this._expandedFilter === ev.currentTarget.localName) {
this._expandedFilter = undefined;
}
}
private _filterChanged(
ev: HASSDomEvent<DataTableFilter> & HASSDomCurrentTargetEvent<HTMLElement>
) {
ev.stopPropagation();
const type = ev.currentTarget.localName;
this._itemFilterValues[type] = ev.detail.value;
this._filteredItems = {
...this._filteredItems,
[type]: (
Array.isArray(ev.detail.value)
? ev.detail.value.length
: ev.detail.value &&
Object.values(ev.detail.value).some((values) => values.length)
)
? ev.detail.items
: undefined,
};
this.filterValues = { ...this.filterValues, [type]: ev.detail.value };
fireEvent(this, "entity-table-filters-changed", {
value: this.filterValues,
});
}
private _clearFilter() {
this._filteredItems = {};
this._itemFilterValues = {};
this.filterValues = {};
fireEvent(this, "entity-table-filters-changed", {
value: this.filterValues,
});
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
ev.stopPropagation();
fireEvent(this, "row-click", ev.detail, { bubbles: false });
}
private async _loadSubEntries(entryId: string) {
const entries = await getSubEntries(this.hass, entryId);
if (this._subEntryConfigEntry === entryId) {
this._subEntries = entries;
}
}
private _fetchExposedEntities = async () => {
try {
this._exposedEntities = (
await listExposedEntities(this.hass)
).exposed_entities;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to fetch exposed entities", err);
this._exposedEntities = {};
}
};
static styles = css`
:host {
display: block;
height: 100%;
}
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}
hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px;
}
hass-loading-screen {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
`;
}
declare global {
interface HASSDomEvents {
"entity-table-filter-context-changed": EntityTableFilterContext;
"entity-table-filters-changed": { value: DataTableFiltersValues };
}
interface HTMLElementTagNameMap {
"ha-entity-table": HaEntityTable;
}
}
+6
View File
@@ -232,6 +232,12 @@ class HaPanelConfig extends HassRouterPage {
import("./integrations/integration-panels/serial/serial-config-dashboard"),
waitForReady: true,
},
modbus: {
tag: "modbus-config-dashboard",
load: () =>
import("./integrations/integration-panels/modbus/modbus-config-dashboard"),
waitForReady: true,
},
dhcp: {
tag: "dhcp-config-panel",
load: () =>
@@ -0,0 +1,521 @@
import {
mdiCableData,
mdiLan,
mdiPuzzle,
mdiRefresh,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { domainToName } from "../../../../../data/integration";
import type { ModbusConnection } from "../../../../../data/modbus";
import {
listModbusConnections,
modbusEndpointTarget,
modbusSerialDevice,
modbusUnitCount,
} from "../../../../../data/modbus";
import "../../../../../layouts/hass-subpage";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
const TRANSPORTS = ["tcp", "udp", "serial"] as const;
type ModbusTransport = (typeof TRANSPORTS)[number];
const isKnownTransport = (transport: string): transport is ModbusTransport =>
(TRANSPORTS as readonly string[]).includes(transport);
interface ConnectionHolder {
entryId: string;
entry?: ConfigEntry;
units: number[];
}
interface ConnectionListItem {
icon: string;
primary: string;
transport: string;
state: string;
serialDevice?: string;
holders: ConnectionHolder[];
connection: ModbusConnection;
}
@customElement("modbus-config-dashboard")
export class ModbusConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _connections?: ModbusConnection[];
@state() private _entries?: Record<string, ConfigEntry>;
@state() private _error?: string;
protected async firstUpdated(): Promise<void> {
await this._fetchConnections();
await panelIsReady(this);
}
private async _fetchConnections(): Promise<void> {
try {
const [connections, entries] = await Promise.all([
listModbusConnections(this.hass),
getConfigEntries(this.hass),
]);
this._connections = connections;
this._entries = Object.fromEntries(
entries.map((entry) => [entry.entry_id, entry])
);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _transportName(transport: string): string {
return isKnownTransport(transport)
? this.hass.localize(`ui.panel.config.modbus.transport.${transport}`)
: transport;
}
// A serial port is open or closed. Opening one says nothing about whether a
// device answers on the wire, which a network connection's handshake does.
private _stateName(connected: boolean, serial: boolean): string {
if (serial) {
return this.hass.localize(
`ui.panel.config.modbus.state_${connected ? "open" : "closed"}`
);
}
return this.hass.localize(
`ui.panel.config.modbus.state_${connected ? "connected" : "not_connected"}`
);
}
private _connectionListItem(
connection: ModbusConnection,
entries: Record<string, ConfigEntry>
): ConnectionListItem {
const [transport] = connection.endpoint;
const serialDevice = modbusSerialDevice(connection.endpoint);
return {
icon: serialDevice ? mdiCableData : mdiLan,
primary: modbusEndpointTarget(connection.endpoint),
transport: this._transportName(transport),
state: this._stateName(connection.connected, serialDevice !== undefined),
serialDevice,
holders: Object.entries(connection.units).map(([entryId, units]) => ({
entryId,
entry: entries[entryId],
units,
})),
connection,
};
}
private _sortedConnections = memoizeOne(
(
connections: ModbusConnection[],
entries: Record<string, ConfigEntry>,
_localize: HomeAssistant["localize"],
language: string
): ConnectionListItem[] =>
connections
.map((connection) => this._connectionListItem(connection, entries))
.sort((a, b) =>
caseInsensitiveStringCompare(a.primary, b.primary, language)
)
);
private _renderUnits(holder: ConnectionHolder): TemplateResult {
return html`
<div slot="supporting-text">
${this.hass.localize("ui.panel.config.modbus.units", {
count: holder.units.length,
ids: holder.units.join(", "),
})}
</div>
`;
}
private _renderHolder(holder: ConnectionHolder): TemplateResult {
// An entry removed while the connection was listed has nowhere to link to
if (!holder.entry) {
return html`
<ha-md-list-item class="holder">
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${holder.entryId}</div>
${this._renderUnits(holder)}
</ha-md-list-item>
`;
}
const { domain, title } = holder.entry;
return html`
<ha-md-list-item
type="link"
class="holder"
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
>
<img
slot="start"
.src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${domain}
/>
<div slot="headline">
${title || domainToName(this.hass.localize, domain)}
</div>
${this._renderUnits(holder)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _renderSerialPortLink(): TemplateResult {
return html`
<ha-md-list-item type="link" class="holder" href="/config/serial">
<ha-svg-icon slot="start" .path=${mdiCableData}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.modbus.view_serial_port")}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
// A closed link is not a fault: the library opens one when an integration
// next polls, and a device may drop an idle one in the meantime
private _renderState(item: ConnectionListItem): TemplateResult {
return html`
<div slot="end" class="state">
<span
class="dot ${item.connection.connected ? "online" : "offline"}"
></span>
${item.state}
</div>
`;
}
private _renderConnectionItem(item: ConnectionListItem): TemplateResult {
return html`
<ha-md-list-item class="connection">
<ha-svg-icon slot="start" .path=${item.icon}></ha-svg-icon>
<div slot="headline">${item.primary}</div>
<div slot="supporting-text">${item.transport}</div>
${this._renderState(item)}
</ha-md-list-item>
${item.holders.map((holder) => this._renderHolder(holder))}
${
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
? this._renderSerialPortLink()
: nothing
}
`;
}
private _renderConnectionsCard(items: ConnectionListItem[]): TemplateResult {
return html`
<ha-card class="connections">
<div class="card-header">
${this.hass.localize("ui.panel.config.modbus.connections")}
</div>
<div class="card-content">
<div class="description">
${this.hass.localize(
"ui.panel.config.modbus.connections_description"
)}
</div>
<ha-md-list>
${items.map((item) => this._renderConnectionItem(item))}
</ha-md-list>
</div>
</ha-card>
`;
}
// No health verdict: the summary is a count, since a closed link is normal
private _renderStatusCard(items: ConnectionListItem[]): TemplateResult {
return html`
<ha-card class="status">
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon .path=${mdiTransitConnectionVariant}></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize("ui.panel.config.modbus.status_summary", {
units: items.reduce(
(total, item) => total + modbusUnitCount(item.connection),
0
),
total: items.length,
})}
</div>
</div>
</div>
</ha-card>
`;
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.modbus.title")}
back-path="/config/connectivity"
>
<ha-icon-button
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.refresh")}
.path=${mdiRefresh}
@click=${this._handleRefresh}
></ha-icon-button>
<div class="container">${this._renderContent()}</div>
</hass-subpage>
`;
}
private _renderContent(): TemplateResult {
if (this._error !== undefined) {
return html`
<ha-alert
alert-type="error"
.title=${this.hass.localize("ui.panel.config.modbus.loading_error")}
>
${this._error}
</ha-alert>
`;
}
if (!this._connections || !this._entries) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
const items = this._sortedConnections(
this._connections,
this._entries,
this.hass.localize,
this.hass.locale.language
);
if (!items.length) {
return html`
<ha-card>
<div class="card-content">
<div class="empty">
${this.hass.localize("ui.panel.config.modbus.no_connections")}
<br />
<small>
${this.hass.localize(
"ui.panel.config.modbus.no_connections_description"
)}
</small>
</div>
</div>
</ha-card>
`;
}
return html`
${this._renderStatusCard(items)} ${this._renderConnectionsCard(items)}
`;
}
private _handleRefresh(): void {
this._fetchConnections();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card,
ha-alert {
display: block;
margin: 0px auto var(--ha-space-4);
max-width: 600px;
}
ha-card:first-child {
margin-top: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12) 0;
}
ha-card.connections {
overflow: hidden;
}
ha-card.connections .card-content {
padding: 0;
}
ha-card.connections .card-header {
padding-bottom: var(--ha-space-2);
}
.status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 0;
--md-list-item-two-line-container-height: 0;
--md-list-item-three-line-container-height: 0;
}
ha-md-list-item.connection:not(:first-child) {
border-top: 1px solid var(--divider-color);
margin-top: var(--ha-space-2);
--md-list-item-top-space: var(--ha-space-4);
}
.state {
display: flex;
align-items: center;
column-gap: var(--ha-space-2);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.state .dot {
width: var(--ha-space-2);
height: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
background-color: var(--disabled-text-color);
}
.state .dot.online {
background-color: var(--success-color);
}
ha-md-list-item.holder {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.holder img[slot="start"] {
width: 24px;
height: 24px;
}
.description {
padding: 0 var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-4);
color: var(--secondary-text-color);
}
.empty small {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"modbus-config-dashboard": ModbusConfigDashboard;
}
}
@@ -8,6 +8,7 @@ import {
mdiPowerPlugOff,
mdiPuzzle,
mdiRefresh,
mdiTransitConnectionVariant,
mdiUsb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
@@ -29,6 +30,10 @@ import {
domainToName,
getConfigPanelPath,
} from "../../../../../data/integration";
import {
listModbusConnections,
modbusSerialDevice,
} from "../../../../../data/modbus";
import type {
SerialPort,
SerialPortConsumer,
@@ -102,6 +107,8 @@ export class SerialConfigDashboard extends LitElement {
@state() private _ports?: SerialPortUsage[];
@state() private _modbusDevices?: Set<string>;
@state() private _error?: string;
protected async firstUpdated(): Promise<void> {
@@ -111,13 +118,36 @@ export class SerialConfigDashboard extends LitElement {
private async _fetchPorts(): Promise<void> {
try {
this._ports = await listSerialPortsWithUsage(this.hass);
const [ports, modbusConnections] = await Promise.all([
listSerialPortsWithUsage(this.hass),
// Modbus only annotates the ports; failing to reach it is not an error
isComponentLoaded(this.hass.config, "modbus")
? listModbusConnections(this.hass).catch(() => [])
: [],
]);
this._ports = ports;
this._modbusDevices = new Set(
modbusConnections
.map((connection) => modbusSerialDevice(connection.endpoint))
.filter((device) => device !== undefined)
);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
// Modbus keeps the device path it was configured with verbatim, which may be
// an alias of the scanned path, so both are matched
private _usedByModbus(port: SerialPort): boolean {
return (
this._modbusDevices !== undefined &&
(this._modbusDevices.has(port.device) ||
(port.resolved_device !== null &&
this._modbusDevices.has(port.resolved_device)))
);
}
private _portListItem(
port: SerialPortUsage,
localize: HomeAssistant["localize"]
@@ -308,6 +338,21 @@ export class SerialConfigDashboard extends LitElement {
`;
}
private _renderModbusLink(): TemplateResult {
return html`
<ha-md-list-item type="link" class="consumer" href="/config/modbus">
<ha-svg-icon
slot="start"
.path=${mdiTransitConnectionVariant}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.serial.used_by_modbus")}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _continueFlow(ev: Event): void {
showConfigFlowDialog(this, {
continueFlowId: (ev.currentTarget as any).flowId,
@@ -372,6 +417,7 @@ export class SerialConfigDashboard extends LitElement {
}
</ha-md-list-item>
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
${this._usedByModbus(item.port) ? this._renderModbusLink() : nothing}
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
`;
}
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const entities = this._filterEntities(
this.params.exposedEntities,
this._registries,
this._filter
);
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _filterEntities = memoizeOne(
(
exposedEntities: Record<string, ExposeEntitySettings>,
registries: ContextType<typeof registriesContext>,
filter?: string
): FilteredEntity[] => {
const lowerFilter = filter?.toLowerCase();
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const nameList = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._registries.floors
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
registries.entities,
registries.devices,
registries.areas,
registries.floors
);
if (!lowerFilter) {
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
continue;
}
const [, deviceName, areaName] = nameList;
const [, deviceName, parentDeviceName, areaName] = nameList;
if (deviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _renderItem = (item: FilteredEntity) => {
const { entity: entityState, nameList } = item;
const [entityName, deviceName, areaName] = nameList;
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this._config?.userData?.showEntityIdPicker;
+1 -17
View File
@@ -61,8 +61,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@state() private _stateItems?: HassEntity[];
@state() private _activeEntry = "";
@state() private _canEditCore = false;
@query("ha-locations-editor") private _map?: HaLocationsEditor;
@@ -151,7 +149,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
?selected=${this._activeEntry === entry.id}
>
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
${entry.name}
@@ -185,7 +182,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
?selected=${this._activeEntry === stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
@@ -273,7 +269,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@marker-clicked=${this._markerClicked}
></ha-locations-editor>
<div class="overflow">${listBox}</div>
</div>
@@ -365,7 +360,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _locationUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
latitude: ev.detail.location[0],
@@ -384,7 +378,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _radiusUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
radius: Math.round(ev.detail.radius),
@@ -400,10 +393,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
});
}
private _markerClicked(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
}
private _createZone() {
this._openDialog();
}
@@ -417,9 +406,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._openEditEntry(ev);
return;
}
const entryId: string = (ev.currentTarget! as any).value;
this._zoomZone(entryId);
this._activeEntry = entryId;
this._zoomZone((ev.currentTarget! as any).value);
}
private _stateItemClicked(ev: CustomEvent) {
@@ -435,7 +422,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
this._zoomZone(entryId);
this._activeEntry = entryId;
}
private async _zoomZone(id: string) {
@@ -478,7 +464,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow) {
return;
}
this._activeEntry = created.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(created.id);
@@ -505,7 +490,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow || !fitMap) {
return;
}
this._activeEntry = entry.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(entry.id);
+8 -1
View File
@@ -270,6 +270,7 @@ class HaPanelHistory extends LitElement {
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
inside-labels
>
</state-history-charts>
`
@@ -812,7 +813,13 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: var(--ha-space-4);
}
/* Narrow screens: keep the vertical rhythm but give the charts the
horizontal space back. */
:host([narrow]) .results {
padding-inline: var(--ha-space-2);
}
.progress-wrapper {
+18 -12
View File
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
export interface EntityDisplay {
@@ -60,14 +60,20 @@ export const entityDisplay = (
return {};
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || entityId;
@@ -82,11 +88,11 @@ export const entityDisplay = (
parts = [];
break;
case "device":
parts = [deviceQualifier];
parts = [parentDeviceName, deviceQualifier];
break;
case "area":
default:
parts = [areaName, deviceQualifier];
parts = [areaName, parentDeviceName, deviceQualifier];
}
const filtered = parts.filter(Boolean) as string[];
+4 -10
View File
@@ -7,7 +7,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
import type { LovelaceBadge } from "../types";
@@ -68,6 +67,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
if (this.hass) {
this._element.hass = this.hass;
}
this._element.preview = this.preview;
// Update element when the visibility of the badge changes, e.g. custom badge
this._element.addEventListener("badge-visibility-changed", (ev: Event) => {
ev.stopPropagation();
@@ -129,11 +129,12 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
}
}
}
if (changedProps.has("hass")) {
if (changedProps.has("hass") || changedProps.has("preview")) {
try {
if (this.hass) {
this._element.hass = this.hass;
}
this._element.preview = this.preview;
} catch (e: any) {
this._loadElement(createErrorBadgeConfig(e.message, null));
}
@@ -165,14 +166,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
this._setElementVisibility(visible);
}
@@ -28,6 +28,8 @@ export class HuiEntityFilterBadge
private _elements?: HuiBadge[];
private _placeholderBadge?: HTMLElement;
private _configEntities?: EntityFilterEntityConfig[];
private _oldEntities?: EntityFilterEntityConfig[];
@@ -56,6 +58,7 @@ export class HuiEntityFilterBadge
this.removeChild(this.lastChild);
}
this._elements = undefined;
this._placeholderBadge = undefined;
this._configEntities = processConfigEntities(config.entities);
this._oldEntities = undefined;
@@ -69,6 +72,7 @@ export class HuiEntityFilterBadge
protected shouldUpdate(changedProperties: PropertyValues): boolean {
if (
changedProperties.has("_config") ||
changedProperties.has("preview") ||
(changedProperties.has("hass") &&
this._haveEntitiesChanged(
changedProperties.get("hass") as HomeAssistant | undefined
@@ -112,33 +116,42 @@ export class HuiEntityFilterBadge
});
if (entitiesList.length === 0) {
this.style.display = "none";
this._oldEntities = entitiesList;
return;
}
const isSame =
this._oldEntities &&
entitiesList.length === this._oldEntities.length &&
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
if (!this.preview) {
this._placeholderBadge = undefined;
this.style.display = "none";
return;
}
if (!isSame) {
this._elements = [];
for (const badgeConfig of entitiesList) {
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.preview;
element.config = {
type: "entity",
...badgeConfig,
};
element.load();
this._elements.push(element);
this._placeholderBadge ??= document.createElement("ha-badge");
} else {
this._placeholderBadge = undefined;
const isSame =
!changedProperties.has("preview") &&
this._oldEntities &&
entitiesList.length === this._oldEntities.length &&
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
if (!isSame) {
this._elements = [];
for (const badgeConfig of entitiesList) {
const element = document.createElement("hui-badge");
element.hass = this.hass;
element.preview = this.preview;
element.config = {
type: "entity",
...badgeConfig,
};
element.load();
this._elements.push(element);
}
this._oldEntities = entitiesList;
}
this._oldEntities = entitiesList;
}
if (!this._elements) {
if (!this._elements && !this._placeholderBadge) {
return;
}
@@ -146,8 +159,12 @@ export class HuiEntityFilterBadge
this.removeChild(this.lastChild);
}
for (const element of this._elements) {
this.appendChild(element);
if (this._placeholderBadge) {
this.appendChild(this._placeholderBadge);
} else {
for (const element of this._elements!) {
this.appendChild(element);
}
}
this.style.display = "flex";
@@ -530,14 +530,6 @@ class HuiTemperatureForecastCardFeature
flex-direction: column;
justify-content: center;
align-items: stretch;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.container.with-labels {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.bars {
@@ -564,6 +556,9 @@ class HuiTemperatureForecastCardFeature
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
`,
];
@@ -36,9 +36,14 @@ import { hasConfigChanged } from "../../common/has-changed";
import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
round(value ?? 0, 3) > 0;
const periodIncludesNow = (data: EnergyData): boolean =>
!data.end || data.end.getTime() >= Date.now();
@@ -843,8 +848,8 @@ class HuiEnergyDistrubutionCard
? svg`<path
id="battery-grid"
class=${classMap({
"battery-from-grid": Boolean(batteryFromGrid),
"battery-to-grid": Boolean(batteryToGrid),
"battery-from-grid": hasFlow(batteryFromGrid),
"battery-to-grid": hasFlow(batteryToGrid),
})}
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
vector-effect="non-scaling-stroke"
@@ -875,14 +880,14 @@ class HuiEnergyDistrubutionCard
: nothing
}
${
solarToGrid && this._animate
hasFlow(solarToGrid) && this._animate
? svg`<circle
r="1"
class="return"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (solarToGrid / totalLines) * 6}s"
dur="${6 - (solarToGrid / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
@@ -892,7 +897,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarConsumption && this._animate
hasFlow(solarConsumption) && this._animate
? svg`<circle
r="1"
class="solar"
@@ -909,7 +914,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
gridConsumption && this._animate
hasFlow(gridConsumption) && this._animate
? svg`<circle
r="1"
class="grid"
@@ -926,7 +931,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarToBattery && this._animate
hasFlow(solarToBattery) && this._animate
? svg`<circle
r="1"
class="battery-solar"
@@ -943,7 +948,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryConsumption && this._animate
hasFlow(batteryConsumption) && this._animate
? svg`<circle
r="1"
class="battery-house"
@@ -960,7 +965,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryFromGrid && this._animate
hasFlow(batteryFromGrid) && this._animate
? svg`<circle
r="1"
class="battery-from-grid"
@@ -978,7 +983,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryToGrid && this._animate
hasFlow(batteryToGrid) && this._animate
? svg`<circle
r="1"
class="battery-to-grid"
@@ -348,6 +348,7 @@ class HuiEnergySankeyCard
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
@@ -347,6 +347,7 @@ class HuiPowerSankeyCard
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
+1 -9
View File
@@ -9,7 +9,6 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { tryCreateCardElement } from "../create-element/create-card-element";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
@@ -262,14 +261,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
return;
}
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
const visible = conditionsMet ?? this._conditionsVisible();
this._setElementVisibility(visible);
}
@@ -172,9 +172,11 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
: nothing
}
${
this._config.heading
? html`<p>${this._config.heading}</p>`
: nothing
this._config.heading && style === "subtitle"
? html`<h3 class="heading">${this._config.heading}</h3>`
: this._config.heading
? html`<h2 class="heading">${this._config.heading}</h2>`
: nothing
}
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
</div>
@@ -275,9 +277,12 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
display: flex;
flex: none;
}
.content p {
.content .heading {
margin: 0;
font-style: normal;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

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