Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld f3201c2f2f Show dual-axis view for color lights that support color temperature 2026-08-19 16:31:49 +02:00
Maarten Lakerveld 89869fbd5c Add dual-axis temperature/brightness view to light more-info dialog 2026-08-19 12:57:55 +02:00
Maarten Lakerveld 2495976fca Add ha-color-temp-brightness-picker 2D pad component with gallery page 2026-08-19 12:57:55 +02:00
Petar PetrovandGitHub 089592e7e3 Ignore stale History statistics fetches after the selection changes (#53700)
* Ignore stale History statistics fetches after the selection changes.

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

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

---------

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

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

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

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

* Remove dead code breaking the type check

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

* Keep the target picker out of the redesign

* Remember the source filters across visits

* Truncate timeline names that do not fit the plot

* Show device class icons in the sources filter

* Open the sources pane by default on wide screens

* Show the entity count in the sources chip

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

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

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

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

---------

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

* Add central Home Assistant node to Matter network graph

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

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

* Pass devices to getDeviceArea so bridged nodes inherit their area

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

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

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

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

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

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

* Colour Matter graph links by transport and float unknown neighbours

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

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

* Float Matter nodes whose route to Home Assistant is unknown

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

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

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

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

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

* Carry the transport colour onto the Home Assistant edges

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

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

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

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

* Give the border router node its transport colour too

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

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

* Improve MQTT subscribe panel formatting and add copy button

* Standardize MQTT options flow button to a nav card style

* Refactor MQTT dashboard

* Improve style

* Add link to broker connection settings

* Test the effect of not exporting the SVG data

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

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

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

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

* Keep await restart stages out of MEDIA_STAGES

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

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

* Group backup setup stages with the first progress segment

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

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

---------

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

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

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

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

* Make max_devices budget named devices, not nodes

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

* Extract the sankey device cap into findDevicesOverCap

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

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

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

* Align app E2E navigation with production

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

---------

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

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

* use hasAnyAction helper

---------

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

* Don't delete preexisting user on cancel submit

* retry ci

* Don't show choice when no floating users exist

* collapse to single dialog

---------

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


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

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

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

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

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

* Fix

* Preserve existing URL hash in createMoreInfoUrl and removeMoreInfoUrl

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

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

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

---------

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

* Apply review fixes to activity detail dialog

* Resolve previous state from history in logbook detail dialog

* Disable text selection on clickable logbook rows

* Document run discovery as a fallback for missing context ids

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

* Replace clickable logbook rows with explicit detail affordances

* Show the cause badge on automation and integration rows

* Map scheduled and Home Assistant causes to their trigger icons

* Replace logbook detail affordances with full-width rows

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

* Add missing import

* Fix milliseconds placement in activity chain times
2026-08-14 15:57:03 +03:00
Bram KragtenandGitHub f23c8f0e23 Fix sub device tree indicator (#53638) 2026-08-14 09:18:06 +01:00
Petar PetrovandGitHub f578cb0526 Guard registry lookups in the target picker item row (#53637) 2026-08-14 09:55:54 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
78878897cc Update dependency typescript-eslint to v8.67.0 (#53634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 06:46:42 +02:00
9472d8ad12 Add device context to energy individual device names (#53603)
Add device context to energy individual device labels

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

* Detect stacked features from the slot

* Rename tile container layout classes

* Rename the below features in the layout helper

* Keep area card features below at full width

* Stretch the area card features below the inline one

* Keep area card features compact outside the compact type

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

This reverts commit 48268cb4e2.

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

This reverts commit e10bb27e2f.

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

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

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

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

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

* Add child devices to demo and gallery mock data

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

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

* Refine child devices UI and target resolution

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

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

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

* Address review on child devices UI

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

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

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

* Fix formatting in device picker row renderer

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

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

* Make the child device tree connector span the full row

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

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

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

---------

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

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

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

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

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

* reduce vertical gap for inline features

---------

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

* Use same slot layout

* Fill tabs

* Move content rendering to dedicated function

* Self review

* Save dashboard details before background

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

* Protect strategy dashboard config

* Apply dashboard background review fixes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-13 16:27:34 +03:00
Petar PetrovandGitHub 5224fa2048 Seed selector values when enabling optional service fields (#53629) 2026-08-13 15:27:07 +02:00
160 changed files with 11776 additions and 3028 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:javascript-typescript"
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
"app": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
},
"frontend-legacy": {
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
}
}
+35
View File
@@ -51,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
+5
View File
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
+6
View File
@@ -1,6 +1,12 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
+1
View File
@@ -57,6 +57,7 @@ export default [
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
"ha-color-temp-brightness-picker",
],
},
{
@@ -0,0 +1,4 @@
---
title: Color temperature and brightness picker
subtitle: Dual-axis pad controlling color temperature and brightness together
---
@@ -0,0 +1,102 @@
import "../../../../src/components/ha-color-temp-brightness-picker";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-slider";
const MIN_KELVIN = 2000;
const MAX_KELVIN = 6500;
@customElement("demo-components-ha-color-temp-brightness-picker")
export class DemoHaColorTempBrightnessPicker extends LitElement {
@state()
value: [number, number] = [3000, 50];
@state()
liveValue?: [number, number];
private _valueCursor(ev) {
this.liveValue = ev.detail.value;
}
private _valueChanged(ev) {
this.value = ev.detail.value;
}
private _kelvinChanged(ev) {
this.value = [Number(ev.target.value), this.value[1]];
}
private _brightnessChanged(ev) {
this.value = [this.value[0], Number(ev.target.value)];
}
protected render(): TemplateResult {
const [kelvin, brightness] = this.liveValue ?? this.value;
return html`
<ha-card>
<div class="card-content">
<p class="value">${kelvin} K - ${brightness}%</p>
<ha-color-temp-brightness-picker
.value=${this.value}
.minKelvin=${MIN_KELVIN}
.maxKelvin=${MAX_KELVIN}
@value-changed=${this._valueChanged}
@cursor-moved=${this._valueCursor}
></ha-color-temp-brightness-picker>
<p>Color temperature: ${this.value[0]} K</p>
<ha-slider
labeled
step="1"
.min=${MIN_KELVIN}
.max=${MAX_KELVIN}
.value=${this.value[0]}
@change=${this._kelvinChanged}
>
</ha-slider>
<p>Brightness: ${this.value[1]}%</p>
<ha-slider
labeled
step="1"
min="1"
max="100"
.value=${this.value[1]}
@change=${this._brightnessChanged}
>
</ha-slider>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
align-items: center;
flex-direction: column;
}
ha-color-temp-brightness-picker {
width: 320px;
height: 320px;
}
.value {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
}
}
@@ -1,4 +1,3 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
@@ -155,9 +153,6 @@ interface Sample {
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
</ha-settings-row>
`
)}
@@ -152,6 +152,84 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
+79 -1
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,6 +340,84 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
+1 -11
View File
@@ -15,7 +15,6 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -28,8 +27,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -41,8 +38,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -58,10 +53,7 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
VacuumEntityFeature.RETURN_HOME,
status: "Returning to dock",
},
},
@@ -96,8 +88,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+7 -8
View File
@@ -89,7 +89,7 @@
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
@@ -103,11 +103,11 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.17",
"hls.js": "1.7.0",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.3",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -151,8 +151,8 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.9.0",
"globals": "17.11.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -223,7 +223,6 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
+19
View File
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
+1
View File
@@ -25,6 +25,7 @@ export type LocalizeKeys =
| `ui.dialogs.unsupported.reasons.${string}`
| `ui.panel.config.${string}.${"caption" | "description"}`
| `ui.panel.config.dashboard.${string}`
| `ui.panel.config.mqtt.${string}`
| `ui.panel.config.storage.segments.${string}`
| `ui.panel.config.zha.${string}`
| `ui.panel.config.zwave_js.${string}`
@@ -1,4 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
+64
View File
@@ -0,0 +1,64 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -22,6 +23,10 @@ import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -60,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -67,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}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}
@@ -177,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -193,14 +215,22 @@ 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,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -224,37 +254,52 @@ 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
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
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,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
@@ -398,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +231,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}
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
+17 -15
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,6 +1158,11 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-right: 16px;
padding-left: 16px;
padding-inline: var(--_cell-padding-inline);
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
width: 60px;
min-width: 60px;
}
@@ -1379,8 +1377,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: initial;
}
@@ -1388,8 +1385,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-end: var(--_cell-padding-inline);
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1516,11 +1512,17 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
@@ -0,0 +1,79 @@
import { mdiCalendar } from "@mdi/js";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import "../chips/ha-assist-chip";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-svg-icon";
import {
haDateRangePickerStyles,
HaDateRangePicker,
} from "./ha-date-range-picker";
/**
* Date range picker as a single pill that also steps through ranges: a
* previous button, the selected range and a next button. Meant for a toolbar,
* next to other chips.
*/
@customElement("ha-date-range-nav")
export class HaDateRangeNav extends HaDateRangePicker {
protected override _renderField() {
return html`
<ha-icon-button-prev
class="step"
.label=${this._i18n.localize("ui.common.previous")}
.disabled=${this.disabled}
@click=${this._handlePrev}
></ha-icon-button-prev>
<ha-assist-chip
id="field"
class="range"
.label=${this._formatRange(" ")}
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button-next
class="step"
.label=${this._i18n.localize("ui.common.next")}
.disabled=${this.disabled}
@click=${this._handleNext}
></ha-icon-button-next>
`;
}
static override styles = [
haDateRangePickerStyles,
css`
/* The three controls read as one pill, with the range chip's borders as
the dividers between them. */
.date-range-inputs {
gap: 0;
border: 1px solid var(--outline-color);
border-radius: var(--ha-assist-chip-container-shape, 10px);
background: var(--ha-assist-chip-container-color, transparent);
overflow: hidden;
width: fit-content;
}
.step {
--ha-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.range {
--md-assist-chip-outline-color: transparent;
--ha-assist-chip-container-shape: 0;
--ha-assist-chip-container-color: transparent;
border-inline: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-nav": HaDateRangeNav;
}
}
+112 -128
View File
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -11,10 +10,7 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -42,18 +38,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
"now-30d",
];
export const haDateRangePickerStyles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
--ha-space-8
)
);
overflow: hidden;
}
`;
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
protected _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -143,73 +188,7 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
</div>
<div class="date-range-inputs">${this._renderField()}</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -248,6 +227,60 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -303,12 +336,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
private _handleNext(ev: MouseEvent): void {
protected _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
protected _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -336,7 +369,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
protected _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -352,7 +385,7 @@ export class HaDateRangePicker extends LitElement {
});
}
private _handleKeydown(ev: KeyboardEvent) {
protected _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -369,56 +402,7 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
static styles = [haDateRangePickerStyles];
}
declare global {
+1
View File
@@ -8,6 +8,7 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
+103 -38
View File
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -26,7 +28,9 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
@@ -375,6 +438,8 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+3
View File
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property({ type: Boolean }) public disabled = false;
@property() public variant:
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.disabled=${this.disabled}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${
+29 -7
View File
@@ -33,6 +33,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityName } from "../common/entity/compute_entity_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
@@ -752,6 +753,19 @@ export class HaCodeEditor extends ReactiveElement {
this._states![key]
);
const entityName = computeEntityName(
this._states![key],
this._registries!.entities,
this._registries!.devices
);
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
: undefined;
const completionItems: CompletionItem[] = [
{
label: this._i18n!.localize(
@@ -759,31 +773,39 @@ export class HaCodeEditor extends ReactiveElement {
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this._states![key].state === formattedState
? undefined
: this._states![key].state,
},
];
if (context.device && context.device.name) {
if (entityName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.entity.entity-picker.entity"
),
value: entityName,
});
}
if (deviceName) {
completionItems.push({
label: this._i18n!.localize("ui.components.device-picker.device"),
value: context.device.name,
value: deviceName,
});
}
if (context.area && context.area.name) {
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
value: context.area.name,
value: areaName,
});
}
if (context.floor && context.floor.name) {
if (floorName) {
completionItems.push({
label: this._i18n!.localize("ui.components.floor-picker.floor"),
value: context.floor.name,
value: floorName,
});
}
@@ -0,0 +1,315 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { rgb2hex } from "../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
// Dark end of the vertical brightness gradient; not fully black so the
// temperature hue stays visible at the bottom of the pad
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
// The marker color's luminance scales with brightness down to this floor,
// so the marker never renders fully black at minimum brightness
const MARKER_LUMINANCE_FLOOR = 0.2;
@customElement("ha-color-temp-brightness-picker")
export class HaColorTempBrightnessPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
/** [color temperature in kelvin, brightness percentage] */
@property({ attribute: false })
public value?: [number, number];
@property({ attribute: false })
public minKelvin?: number;
@property({ attribute: false })
public maxKelvin?: number;
@query("#pad") private _pad?: HTMLElement;
private _mc?: HammerManager;
@state()
private _pressed?: string;
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
@state()
private _cursorPosition?: [number, number];
@state()
private _localValue?: [number, number];
private get _minKelvin() {
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
}
private get _maxKelvin() {
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._setupListeners();
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._destroyListeners();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
const rangeChanged =
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
if (
rangeChanged ||
(changedProps.has("value") &&
(this._localValue?.[0] !== this.value?.[0] ||
this._localValue?.[1] !== this.value?.[1]))
) {
this._resetPosition();
}
}
private _setupListeners() {
if (this._pad && !this._mc) {
this._mc = new Manager(this._pad);
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: this._localValue });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this._localValue });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
}
}
private _destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
private _resetPosition() {
if (this.value === undefined) {
this._cursorPosition = undefined;
this._localValue = undefined;
return;
}
this._cursorPosition = this._getCoordsFromValue(this.value);
this._localValue = this.value;
}
private get _rtl() {
return mainWindow.document.dir === "rtl";
}
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
const xValue =
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
(this._maxKelvin - this._minKelvin);
const x = this._rtl ? 1 - xValue : xValue;
const y = 1 - clamp(value[1], 1, 100) / 100;
return [x, y];
};
private _getValueFromCoord = (x: number, y: number): [number, number] => {
const xValue = this._rtl ? 1 - x : x;
const kelvin = Math.round(
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
);
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
return [kelvin, brightness];
};
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
const boundingRect = this._pad!.getBoundingClientRect();
const x = clamp(
(e.center.x - boundingRect.left) / boundingRect.width,
0,
1
);
const y = clamp(
(e.center.y - boundingRect.top) / boundingRect.height,
0,
1
);
return [x, y];
};
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
render() {
const gradient = this._generateTemperatureGradient(
this._minKelvin,
this._maxKelvin
);
const [x, y] = this._cursorPosition ?? [0, 0];
const markerScale = this._pressed
? this._pressed === "touch"
? "2.5"
: "1.5"
: "1";
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
let markerColor = "#ffffff";
if (this._localValue) {
const rgb = temperature2rgb(this._localValue[0]);
const factor =
MARKER_LUMINANCE_FLOOR +
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
markerColor = rgb2hex([
Math.round(rgb[0] * factor),
Math.round(rgb[1] * factor),
Math.round(rgb[2] * factor),
]);
}
return html`
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
<div
id="pad"
style=${styleMap({
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
})}
></div>
<div
class="cursor"
style=${styleMap({
left: `${x * 100}%`,
top: `${y * 100}%`,
visibility: this._cursorPosition ? undefined : "hidden",
})}
>
<div
class="marker"
style=${styleMap({
"background-color": markerColor,
transform: `translate(${markerOffset}) scale(${markerScale})`,
})}
></div>
</div>
</div>
`;
}
static styles = css`
:host {
display: block;
outline: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
#pad {
width: 100%;
height: 100%;
clip-path: inset(0 round var(--ha-border-radius-6xl));
background-blend-mode: multiply;
background-repeat: no-repeat;
cursor: pointer;
touch-action: none;
}
:host([disabled]) #pad {
cursor: initial;
filter: grayscale(1) opacity(0.5);
}
.cursor {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.marker {
position: absolute;
top: -16px;
left: -16px;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
border: 2px solid white;
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.3),
0 1px 6px rgba(0, 0, 0, 0.15);
}
.container:not(.pressed) .marker {
transition:
transform var(--ha-animation-duration-instant) ease-in-out,
background-color var(--ha-animation-duration-instant) ease-in-out;
}
.container:not(.pressed) .cursor {
transition:
left var(--ha-animation-duration-fast) ease-in-out,
top var(--ha-animation-duration-fast) ease-in-out;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
}
}
+11 -3
View File
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option) => this._renderOption(option)
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
`;
}
private _renderOption(option: ControlSelectOption) {
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${isSelected ? "0" : "-1"}
tabindex=${tabbable ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
+3 -2
View File
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
const value = this.data;
if (value) {
if (this.data) {
const value = { ...this.data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
+79
View File
@@ -0,0 +1,79 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
+287
View File
@@ -0,0 +1,287 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
align-items: center;
}
.header ha-icon-button {
margin-inline-start: initial;
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
+73
View File
@@ -0,0 +1,73 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
+182
View File
@@ -0,0 +1,182 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shows the clear button when above zero. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
@@ -0,0 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
+2
View File
@@ -43,6 +43,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
mqtt: () =>
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
};
@customElement("ha-icon")
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
.localizeValue=${this.localizeValue}
></ha-selector>`;
}
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
: {
[this._activeChoice!]: this.value,
};
this._activeChoice = ev.detail?.value || ev.target.value;
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
value[choice] =
this.selector.choose.choices[choice].selector.constant?.value;
}
fireEvent(this, "value-changed", {
value: {
...value,
+3 -14
View File
@@ -35,6 +35,7 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
+1
View File
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
+268
View File
@@ -0,0 +1,268 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
});
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+57 -302
View File
@@ -41,9 +41,6 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
getTargetComboBoxItemType,
type TargetItem,
type TargetType,
@@ -66,7 +63,6 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@@ -85,8 +81,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public compact = false;
@property({ attribute: false }) public createDomains?: string[];
@property({ type: Boolean, attribute: "primary-entities-only" })
@@ -116,8 +110,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _selectedSection?: TargetTypeFloorless;
@state() private _replaceTarget?: TargetItem;
@@ -158,6 +150,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -252,119 +245,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
}
private _renderValueChips() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="items">
${
floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
</div>
`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
@@ -478,9 +361,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderItems() {
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
return html` ${this._renderValueGroups()} `;
}
private _renderPicker() {
@@ -655,152 +536,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
const type = ev.detail.type;
const itemId = ev.detail.id;
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
if (type === "floor") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === itemId &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
} else if (type === "area") {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.area_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "device") {
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "label") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(itemId) &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(itemId) &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(itemId) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
true,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
let value = this.value;
if (newEntities.length) {
value = this._addItems(value, "entity_id", newEntities);
}
if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices);
}
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, type, itemId);
fireEvent(this, "value-changed", { value });
}
private _handleReplace(
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
) {
@@ -840,17 +575,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTargetAnchor = undefined;
}
private _addItems(
value: this["value"],
type: string,
ids: string[]
): this["value"] {
return {
...value,
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
};
}
private _removeItem(
value: this["value"],
type: TargetType,
@@ -1001,6 +725,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1008,26 +754,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
excludeDeviceIds,
replacingDeviceId,
`device${SEPARATOR}`
).sort(this._sortBySortingLabel);
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
deviceComboBoxKeys,
false
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1245,7 +1006,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1265,27 +1028,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
.end=${(item as { last?: boolean }).last}
slot="start"
></ha-tree-indicator>
`
@@ -1387,13 +1150,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
width: 100%;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: var(--ha-border-width-sm) solid var(--divider-color);
@@ -1409,7 +1165,6 @@ declare global {
interface HASSDomEvents {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
+24 -3
View File
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
</svg>
`;
}
@@ -393,6 +393,9 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -416,6 +419,9 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -424,7 +430,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
@@ -433,7 +439,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
@@ -528,6 +534,12 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -560,6 +572,9 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -585,6 +600,11 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
@@ -1,314 +0,0 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-tooltip";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>`
}
</wa-tag>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device
? computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
: itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObj = this.hass.states[itemId];
return {
name: computeStateName(stateObj) || itemId,
stateObject: stateObj,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl(
{
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
color: var(--primary-text-color);
}
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
}
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
}
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
}
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: var(--ha-space-2) var(--ha-space-1);
display: flex;
}
.expand-btn {
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-value-chip": HaTargetPickerValueChip;
}
}
+51 -11
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${containerOrientationClass}"
class="container ${classMap(containerClasses)}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
</div>
<slot name="features"></slot>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
</div>
`;
}
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.container.horizontal {
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
flex-direction: row;
}
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal ::slotted([slot="features"]) {
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
+1
View File
@@ -29,6 +29,7 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,6 +437,7 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+152 -20
View File
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
@@ -24,12 +25,17 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -69,6 +79,16 @@ export const computeDeviceAreaLabel = (
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -77,7 +97,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -105,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -121,6 +149,7 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -128,26 +157,60 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
return devEntities.some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -155,11 +218,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return true;
}
return entities.every(
return devEntities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -173,11 +236,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -192,8 +255,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -222,7 +285,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName } =
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -259,10 +322,79 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
return outputDevices;
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
};
+69
View File
@@ -81,6 +81,75 @@ export type DeviceRegistryListEntry =
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
+55
View File
@@ -22,6 +22,7 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -36,6 +37,7 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
const getLogbookDataFromServer = (
export const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+5 -2
View File
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type { LovelaceViewRawConfig } from "./view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: string;
background?: LovelaceDashboardBackgroundConfig;
views: LovelaceViewRawConfig[];
}
+4 -1
View File
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;
background?: LovelaceDashboardBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+67
View File
@@ -45,6 +45,73 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength =
"strong" | "medium" | "weak" | "none" | "unknown";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
ssid?: string | null;
bssid?: string | null;
host_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
+33 -11
View File
@@ -14,6 +14,7 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
);
if (hasMatchingdevice) {
return true;
}
@@ -846,6 +843,8 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1112,6 +1111,11 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1131,6 +1135,10 @@ export const resolveEntityIDs = (
expanded.areas.forEach((id) => targetAreas.add(id));
});
// Devices only reached through an area do not pull in entities that are
// explicitly assigned to another area, matching core.
const devicesNotViaArea = new Set(targetDevices);
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
@@ -1143,6 +1151,16 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
devicesNotViaArea.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
@@ -1150,7 +1168,11 @@ export const resolveEntityIDs = (
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.entities.forEach((id) => {
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
targetEntities.add(id);
}
});
});
return Array.from(targetEntities);
+8 -4
View File
@@ -7,7 +7,10 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import type { DeviceRegistryEntry } from "./device/device_registry";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
if (
areaDevices.some((device) =>
@@ -178,6 +179,9 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
+3
View File
@@ -184,6 +184,7 @@ export const checkForEntityUpdates = async (
}
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.checking_updates"),
});
@@ -194,6 +195,7 @@ export const checkForEntityUpdates = async (
if (computeDomain(event.data.entity_id) === "update") {
updated++;
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
count: updated,
}),
@@ -216,6 +218,7 @@ export const checkForEntityUpdates = async (
if (updated === 0) {
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
-2
View File
@@ -22,7 +22,6 @@ export enum VacuumEntityFeature {
STOP = 8,
RETURN_HOME = 16,
FAN_SPEED = 32,
BATTERY = 64,
STATUS = 128,
SEND_COMMAND = 256,
LOCATE = 512,
@@ -34,7 +33,6 @@ export enum VacuumEntityFeature {
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
battery_level?: number;
fan_speed?: string;
fan_speed_list?: string[];
[key: string]: any;
+3 -2
View File
@@ -566,9 +566,10 @@ export const getWeatherStateIcon = (
if (userDefinedIcon) {
return html`
<div
style="background-size: cover;${styleMap({
style=${styleMap({
"background-size": "cover",
"background-image": userDefinedIcon,
})}"
})}
></div>
`;
}
+22
View File
@@ -1,6 +1,7 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -465,6 +466,27 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
@@ -0,0 +1,138 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-color-temp-brightness-picker";
import { apiContext } from "../../../../data/context";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import type { LightEntity } from "../../../../data/light";
import { LightColorMode } from "../../../../data/light";
@customElement("light-color-temp-brightness-picker")
class LightColorTempBrightnessPicker extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: LightEntity;
@state() private _value?: [number, number];
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
}
return html`
<ha-color-temp-brightness-picker
.value=${this._value}
.minKelvin=${this.stateObj.attributes.min_color_temp_kelvin}
.maxKelvin=${this.stateObj.attributes.max_color_temp_kelvin}
.disabled=${this.stateObj.state === UNAVAILABLE}
@cursor-moved=${this._cursorMoved}
@value-changed=${this._valueChanged}
>
</ha-color-temp-brightness-picker>
`;
}
private _updateValues() {
const stateObj = this.stateObj;
if (stateObj.state !== "on") {
this._value = undefined;
return;
}
const kelvin =
stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP
? stateObj.attributes.color_temp_kelvin
: undefined;
const brightness = stateObj.attributes.brightness;
this._value =
kelvin != null && brightness != null
? [kelvin, Math.max(Math.round((brightness * 100) / 255), 1)]
: undefined;
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
this._updateValues();
}
private _cursorMoved(ev: CustomEvent) {
const value = ev.detail.value;
this._isInteracting = value !== undefined;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight();
}
private _throttleUpdateLight = throttle(() => {
this._updateLight();
}, 500);
private _valueChanged(ev: CustomEvent) {
const value = ev.detail.value;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight.cancel();
this._updateLight();
}
private _updateLight() {
const [color_temp_kelvin, brightness_pct] = this._value!;
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
color_temp_kelvin,
brightness_pct,
});
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: flex;
flex-direction: column;
align-items: center;
}
ha-color-temp-brightness-picker {
height: 45vh;
max-height: 320px;
min-height: 200px;
aspect-ratio: 1;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"light-color-temp-brightness-picker": LightColorTempBrightnessPicker;
}
}
+5 -15
View File
@@ -7,21 +7,11 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
+9
View File
@@ -0,0 +1,9 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -41,11 +41,13 @@ import "../components/ha-more-info-state-header";
import "../components/lights/ha-favorite-color-button";
import "../components/lights/ha-more-info-light-favorite-colors";
import "../components/lights/light-color-rgb-picker";
import "../components/lights/light-color-temp-brightness-picker";
import "../components/lights/light-color-temp-picker";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
type MainControl = "brightness" | "color_temp" | "color";
type MainControl =
"brightness" | "color_temp" | "color" | "color_temp_brightness";
@customElement("more-info-light")
class MoreInfoLight extends LitElement {
@@ -78,12 +80,31 @@ class MoreInfoLight extends LitElement {
.attributeValue=${value}
></ha-attribute-icon>`;
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (
changedProps.has("stateObj") &&
this.stateObj &&
this._mainControl === "color_temp_brightness" &&
!this._supportsDualAxis(this.stateObj)
) {
this._mainControl = "brightness";
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (changedProps.has("stateObj")) {
this._effect = this.stateObj?.attributes.effect;
}
}
private _supportsDualAxis(stateObj: LightEntity): boolean {
return (
lightSupportsBrightness(stateObj) &&
lightSupportsColorMode(stateObj, LightColorMode.COLOR_TEMP)
);
}
private _setMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = ev.currentTarget.control;
@@ -128,6 +149,8 @@ class MoreInfoLight extends LitElement {
LightEntityFeature.EFFECT
);
const supportsDualAxis = this._supportsDualAxis(this.stateObj);
const showFavoriteColors = Boolean(
this.entry &&
(this.editMode ||
@@ -183,6 +206,17 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis &&
this._mainControl === "color_temp_brightness"
? html`
<light-color-temp-brightness-picker
.stateObj=${this.stateObj}
>
</light-color-temp-brightness-picker>
`
: nothing
}
<ha-icon-button-group>
${
supportsBrightness
@@ -254,6 +288,26 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis
? html`
<ha-icon-button-toggle
border-only
.selected=${
this._mainControl === "color_temp_brightness"
}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.label=${this._localize(
"ui.dialogs.more_info_control.light.color_temp_brightness"
)}
.control=${"color_temp_brightness"}
@click=${this._setMainControl}
>
<span class="wheel color-temp-brightness"></span>
</ha-icon-button-toggle>
`
: nothing
}
${
supportsWhite
? html`
@@ -384,6 +438,15 @@ class MoreInfoLight extends LitElement {
rgb(255, 160, 0) 100%
);
}
.wheel.color-temp-brightness {
background-image:
linear-gradient(to top, rgb(102, 102, 102), white),
linear-gradient(90deg, rgb(255, 160, 0), white, rgb(166, 209, 255));
background-blend-mode: multiply;
/* Mask the blended result as one layer; radius clipping bleeds
per background layer at the edge in Gecko */
clip-path: inset(0 round var(--ha-border-radius-circle));
}
*[disabled] .wheel {
filter: grayscale(1) opacity(0.5);
}
@@ -159,23 +159,6 @@ class MoreInfoVacuum extends LitElement {
`;
}
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this._i18n.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
@@ -11,6 +11,7 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -48,11 +49,16 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -127,15 +133,33 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -509,8 +533,17 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+62 -2
View File
@@ -17,6 +17,7 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -43,8 +44,10 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -85,6 +88,7 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -102,6 +106,9 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -147,6 +154,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -181,6 +194,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -216,6 +231,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -224,6 +242,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -271,7 +291,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView) {
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -279,6 +302,38 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -306,7 +361,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1007,19 +1064,22 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view);
this._setView(view, true);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
+15
View File
@@ -0,0 +1,15 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
-5
View File
@@ -74,11 +74,6 @@ export class MockVacuumEntity extends MockBaseEntity {
stateAttrs.fan_speed = attrs.fan_speed ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
stateAttrs.battery_level = attrs.battery_level ?? null;
stateAttrs.battery_icon = attrs.battery_icon ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
stateAttrs.status = attrs.status ?? null;
}
+13 -44
View File
@@ -34,6 +34,7 @@ import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/ha-filter-pane-chip";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
@@ -237,20 +238,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing
}
</div>`
? html`<ha-filter-pane-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.path=${mdiFilterVariant}
.count=${this.filters}
.active=${!!this.filters}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
: nothing;
const selectModeBtn =
@@ -471,18 +465,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-assist-chip
<ha-filter-pane-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
></ha-filter-pane-chip>
${
this.filters
? html`<ha-icon-button
@@ -885,24 +875,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.narrow-header-row {
display: flex;
align-items: center;
@@ -951,11 +923,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
.relative {
position: relative;
}
ha-assist-chip {
ha-assist-chip,
ha-filter-pane-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
+40
View File
@@ -6,12 +6,16 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -144,6 +148,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -178,6 +183,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -297,6 +308,35 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -49,6 +49,7 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${this._currentAddon.description}.<br />
<div class="description-text">${this._currentAddon.description}</div>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,6 +1658,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -370,6 +370,7 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -7,6 +7,7 @@ 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 "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -429,20 +430,8 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -2,10 +2,12 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiCloseThick,
mdiCog,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiExclamationThick,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiMenuDown,
@@ -129,6 +131,28 @@ import {
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
const renderIconBadge = (path: string, color: string) => html`
<div
style=${styleMap({
position: "absolute",
top: "-5px",
insetInlineEnd: "-7px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: color,
boxShadow: "0 0 0 2px var(--data-table-background-color)",
color: "var(--data-table-background-color)",
"--mdc-icon-size": "12px",
})}
>
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
</div>
`;
type AutomationItem = AutomationEntity & {
name: string;
area: string | undefined;
@@ -303,6 +327,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
localize: LocalizeFunc,
entitiesToCheck?: any[]
): DataTableColumnContainer<AutomationItem> => {
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
localize,
this.hass
);
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
@@ -310,18 +339,34 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
type: "icon",
moveable: false,
showNarrow: true,
template: (automation) =>
html`<ha-state-icon
.stateObj=${automation}
style=${styleMap({
color:
automation.state === UNAVAILABLE
template: (automation) => {
const unavailable = automation.state === UNAVAILABLE;
const disabled = automation.state === "off";
return html`<div
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
>
<ha-state-icon
.stateObj=${automation}
.stateValue=${unavailable || disabled ? "on" : undefined}
style=${styleMap({
display: "flex",
margin: "0",
color: unavailable
? "var(--error-color)"
: automation.state === "on"
? "var(--state-active-color)"
: disabled
? "var(--disabled-color)"
: "unset",
})}
></ha-state-icon>`,
})}
></ha-state-icon>
${
unavailable
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
: disabled
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
: nothing
}
</div>`;
},
},
entity_id: getEntityIdHiddenTableColumn(),
name: {
@@ -342,23 +387,33 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
area: getAreaTableColumn(localize),
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
last_triggered: {
...triggeredAtColumn,
template: (automation) =>
narrow && automation.state === "off"
? nothing
: triggeredAtColumn.template!(automation),
},
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) =>
narrow
? automation.state === "off"
? localize("ui.panel.config.automation.picker.disabled")
: nothing
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -4,6 +4,7 @@ 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 "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -422,20 +423,8 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -33,23 +33,31 @@ interface ProgressSegment {
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
const ADDON_STAGES: CreateBackupStage[] = [
"addons",
"apps",
// Quick metadata writes emitted while the backup is initialized, before the
// Home Assistant stage (docker_config only by older Supervisors)
const SETUP_STAGES: CreateBackupStage[] = [
"addon_repositories",
"app_repositories",
"docker_config",
];
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Emitted after the backup file is finished, when the backend waits for
// cold-backup add-ons to come back up
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
"await_addon_restarts",
"await_app_restarts",
];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Ordered groups matching actual backend execution order
// Ordered groups matching actual backend execution order. The await restart
// stages share the last creation group to keep the progress monotonic.
const STAGE_ORDER: CreateBackupStage[][] = [
[...SETUP_STAGES, ...HA_STAGES],
ADDON_STAGES,
MEDIA_STAGES,
HA_STAGES,
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
["upload_to_agents"],
["cleaning_up"],
];
@@ -165,21 +173,21 @@ export class HaBackupOverviewProgress extends LitElement {
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.apps"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.apps"
),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -201,18 +209,18 @@ export class HaBackupOverviewProgress extends LitElement {
];
}
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(1, currentGroupIndex),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
+9
View File
@@ -39,6 +39,7 @@ import {
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
@@ -142,6 +143,14 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
iconColor: "#660066",
component: "mqtt",
translationKey: "mqtt",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
@@ -0,0 +1,168 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
const MAX_VISIBLE_CHILD_DEVICES = 10;
@customElement("ha-device-child-devices-card")
export class HaDeviceChildDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@state() public _showAll = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
private _entityCounts = memoizeOne(
(entities: EntityRegistryEntry[]): Record<string, number> => {
const counts: Record<string, number> = {};
for (const entity of entities) {
if (entity.device_id) {
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
}
}
return counts;
}
);
private _childDevices = memoizeOne(
(
deviceId: string,
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices)
.filter((device) => device.parent_device_id === deviceId)
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected render() {
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
if (childDevices.length === 0) {
return nothing;
}
const entityCounts = this._entityCounts(this._entityReg);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(
childDevice,
this.hass.localize,
this.hass.states
)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
<button class="show-more" @click=${this._toggleShowAll}>
${this.hass.localize(
"ui.panel.config.devices.child_devices.show_more",
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
)}
</button>
`
: ""
}
</ha-card>
`;
}
private _toggleShowAll() {
this._showAll = !this._showAll;
}
static styles = css`
:host {
display: block;
}
.card-header {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
button.show-more {
color: var(--primary-color);
text-align: left;
cursor: pointer;
background: none;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
padding: 16px;
font: inherit;
}
button.show-more:focus {
outline: none;
text-decoration: underline;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
}
}
@@ -93,6 +93,27 @@ export class HaDeviceCard extends LitElement {
`
: ""
}
${
this.device.parent_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.part_of"
)}
<span class="hub"
><a
href="/config/devices/device/${
this.device.parent_device_id
}"
>${this._computeDeviceNameDisplay(
this.device.parent_device_id
)}</a
></span
>
</div>
`
: ""
}
${
this.device.via_device_id
? html`
@@ -107,6 +107,7 @@ import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
@@ -900,6 +901,10 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-child-devices-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -459,6 +459,15 @@ export class HaConfigDeviceDashboard extends LitElement {
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
// Ids of devices that have at least one child device, so a parent can be
// grouped together with its children.
const deviceIdsWithChildren = new Set<string>();
for (const dev of Object.values(devices)) {
if (dev.parent_device_id) {
deviceIdsWithChildren.add(dev.parent_device_id);
}
}
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -472,6 +481,15 @@ export class HaConfigDeviceDashboard extends LitElement {
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const parentDevice = device.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
// The device that identifies this device's family: its parent for a
// child device, itself for a device that has children.
const familyParentDevice =
parentDevice ??
(deviceIdsWithChildren.has(device.id) ? device : undefined);
const { areaName } = computeDeviceAreaLabel(
device,
this.hass.areas,
@@ -527,6 +545,29 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[parentDevice.id]
)
: "",
// Grouping key that keeps a device with its family: children group
// under their parent's name, a parent groups under its own name, and
// standalone devices stay ungrouped. The name is always computed from
// the family's parent device with the same arguments, so a parent and
// its children can never end up in different groups. Like the area and
// floor columns, this groups on the display name rather than the id,
// because the data table renders the raw group value as its header.
device_family_name: familyParentDevice
? computeDeviceNameDisplay(
familyParentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[familyParentDevice.id]
)
: undefined,
firmware_version: device.sw_version || undefined,
battery_entity: [
this._batteryEntity(device.id, deviceEntityLookup),
@@ -587,6 +628,16 @@ export class HaConfigDeviceDashboard extends LitElement {
flex: 2,
minWidth: "150px",
extraTemplate: (device) => html`
${
device.parent_device_name
? html`<div style="color: var(--secondary-text-color);">
${localize(
"ui.panel.config.devices.data_table.part_of_device",
{ name: device.parent_device_name }
)}
</div>`
: nothing
}
${
device.label_entries.length
? html`
@@ -607,6 +658,19 @@ export class HaConfigDeviceDashboard extends LitElement {
groupable: true,
minWidth: "120px",
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
// Keyed on the family name so grouping/sorting keeps a parent together
// with its children (grouping uses the column key directly). The cell
// only shows the parent name for child devices. Filterable stays on
// even when hidden, so searching a parent's name surfaces its children.
sortable: true,
filterable: true,
groupable: true,
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -280,6 +299,22 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -276,6 +295,22 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -174,7 +176,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -170,7 +172,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -15,6 +14,7 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,20 +73,18 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
primary: name || entityName || deviceName || statisticId,
secondary,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,12 +10,15 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-tree-indicator";
import {
disableConfigEntry,
type ConfigEntry,
@@ -48,6 +51,13 @@ class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
// Rendered indented under its parent device.
@property({ type: Boolean, reflect: true, attribute: "is-child" })
public isChild = false;
// The last child of its parent, so the tree connector draws its end.
@property({ attribute: false }) public isLastChild = false;
protected render() {
const device = this.device;
@@ -55,6 +65,11 @@ class HaConfigEntryDeviceRow extends LitElement {
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
@@ -65,6 +80,25 @@ class HaConfigEntryDeviceRow extends LitElement {
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
${
this.isChild
? html`<ha-tree-indicator
style=${styleMap({
position: "absolute",
// Span the full row height so consecutive children form one
// continuous line; the elbow sits at the vertical centre.
top: "0",
// Align the connector under the parent device icon; the leading
// space (and thus the icon column) is smaller in narrow mode.
left: rtl ? undefined : this.narrow ? "4px" : "44px",
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${this.isLastChild}
slot="start"
></ha-tree-indicator>`
: nothing
}
<ha-svg-icon
.path=${
device.entry_type === "service"
@@ -351,12 +385,22 @@ class HaConfigEntryDeviceRow extends LitElement {
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
:host([is-child]) ha-md-list-item {
--md-list-item-leading-space: 88px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
:host([narrow][is-child]) ha-md-list-item {
--md-list-item-leading-space: 48px;
}
ha-tree-indicator {
width: 48px;
height: 100%;
}
.vertical-divider {
height: 100%;
width: 1px;
@@ -45,6 +45,7 @@ import {
} from "../../../data/config_entries";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
@@ -479,14 +480,16 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? ownDevices.map(
(device) =>
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)
: nothing
@@ -509,14 +512,16 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${ownDevices.map(
(device) =>
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
`
@@ -15,6 +15,7 @@ import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
@@ -191,14 +192,16 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${devices.map(
(device) =>
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -5,6 +5,7 @@ import {
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -144,6 +145,10 @@ export class MatterConfigDashboard extends LitElement {
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
<ha-button appearance="filled" href="/config/matter/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
@@ -252,6 +257,9 @@ export class MatterConfigDashboard extends LitElement {
}
.nav-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--ha-space-2);
}
@@ -27,6 +27,10 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
visualization: {
tag: "matter-network-visualization",
load: () => import("./matter-network-visualization"),
},
},
};
@@ -0,0 +1,360 @@
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
MatterTopologyStrength,
} from "../../../../../data/matter";
import type { HomeAssistant } from "../../../../../types";
const CATEGORY_HOME_ASSISTANT = 0;
const CATEGORY_BORDER_ROUTER = 1;
const CATEGORY_ROUTER = 2;
const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
// HA is not a Matter node; the frontend synthesizes it as the graph root.
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
strength?: MatterTopologyStrength | null
): number => {
switch (strength) {
case "strong":
return 4;
case "medium":
return 3;
case "weak":
return 2;
// "unknown" (no measurement, link presumed up) sits above "none"/dead so it
// reads as a present link, not a degraded one
case "unknown":
return 2;
default:
return 1;
}
};
// links are colored by transport; signal level stays on the line width.
// Both hues clear 3:1 on the light and the dark card background -- named
// palette colors have no dark variant, so a darker pick would vanish.
export const networkToColorVar = (network?: string | null): string => {
switch (network) {
case "thread":
return "--purple-color";
case "wifi":
return "--orange-color";
// `network` is a plain string on the wire: "ethernet" and anything a newer
// server invents still draw, just neutrally
default:
return "--secondary-text-color";
}
};
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
export const getTopologyNodeCategory = (
node: MatterNetworkTopologyNode
): number => {
if (node.kind === "border_router") {
return CATEGORY_BORDER_ROUTER;
}
if (node.kind === "wifi_ap") {
return CATEGORY_WIFI_AP;
}
if (node.kind === "thread_unknown") {
return CATEGORY_UNKNOWN;
}
if (node.available === false) {
return CATEGORY_OFFLINE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
};
export const getTopologyNodeName = (
node: MatterNetworkTopologyNode,
hass: HomeAssistant
): string => {
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || node.id;
}
if (node.kind === "border_router") {
return (
// many vendors report an identical vendor/model pair on every unit
node.host_name ||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
// the SSID names the network; network_name still holds the BSSID here
node.ssid ||
node.network_name ||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
}
if (node.node_id != null) {
return hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return node.id;
};
const isHub = (category: number): boolean =>
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
// a hub wears its transport's colour, the same hue as the links behind it
const categoryColors = [
style.getPropertyValue("--primary-color"),
style.getPropertyValue(networkToColorVar("thread")),
style.getPropertyValue("--cyan-color"),
style.getPropertyValue("--teal-color"),
style.getPropertyValue(networkToColorVar("wifi")),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
const categories = [
{
name: HOME_ASSISTANT_LABEL,
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.router"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.offline"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.unknown_devices"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
];
const threadNetworks = new Set(
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
);
const multiNetwork = threadNetworks.size > 1;
const nodes: NetworkNode[] = [
{
id: HOME_ASSISTANT_NODE_ID,
name: HOME_ASSISTANT_LABEL,
category: CATEGORY_HOME_ASSISTANT,
value: 4,
symbol: "roundRect",
symbolSize: 45,
polarDistance: 0,
fixed: true,
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
];
const nodeCategories = new Map<string, number>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, hass.areas, hass.devices)
: undefined;
const name = getTopologyNodeName(node, hass);
// an AP is named by its SSID, so its own radio address is what tells two
// radios of one mesh apart; everything else is named by its network
const networkLabel =
node.kind === "wifi_ap"
? node.bssid || node.network_name
: node.ssid || node.network_name;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
// skip a label that just repeats the name, e.g. an AP with no SSID
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
contextParts.push(networkLabel);
}
nodes.push({
id: node.id,
name,
context: contextParts.join(" • ") || undefined,
category,
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
symbol: isHub(category) ? "roundRect" : "circle",
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
itemStyle: {
color: categoryColors[category],
...(node.role === "leader"
? {
borderColor: style.getPropertyValue("--primary-color"),
borderWidth: 2,
}
: {}),
},
polarDistance: isHub(category)
? 0.1
: category === CATEGORY_ROUTER
? 0.4
: 0.8,
});
});
const links: NetworkLink[] = [];
topology.connections.forEach((conn) => {
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
return;
}
// the summary strength is the strongest observed direction, so "none" means
// every direction is dead -- a stale neighbour entry the dashboard also
// refuses to draw
if (conn.strength === "none") {
return;
}
let { source, target } = conn;
let forward = conn.source_to_target;
let reverse = conn.target_to_source;
if (!forward && reverse) {
// normalize so the arrow points in the observed direction
[source, target] = [target, source];
forward = reverse;
reverse = undefined;
}
const oneWay = Boolean(forward) && !reverse;
const asymmetric =
forward && reverse && forward.strength !== reverse.strength;
// an edge is lower confidence when an endpoint is inferred rather than
// commissioned, or is offline -- the dashboard dashes on the same two
const lowConfidence = [source, target].some((id) => {
const category = nodeCategories.get(id);
return category === CATEGORY_UNKNOWN || category === CATEGORY_OFFLINE;
});
const width = strengthToWidth(conn.strength);
links.push({
source,
target,
value: strengthToScale(forward?.strength ?? conn.strength),
// route-table edges without per-direction info are not directional
reverseValue: oneWay
? undefined
: strengthToScale(reverse?.strength ?? conn.strength),
symbolSize: oneWay ? width * 2 + 3 : undefined,
lineStyle: {
width,
color: style.getPropertyValue(networkToColorVar(conn.network)),
type:
oneWay || asymmetric || lowConfidence
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
// Only a hub gets an edge to HA, and it is a real path. A node whose route we
// cannot see gets nothing: inventing an edge to HA reads as a physical link.
// It keeps the HA node's own color rather than a transport hue.
// `symbol: "none"` is what keeps the arrowhead off these edges -- ha-network-graph
// keys arrow suppression on `reverseValue`, not on `value` -- so it must stay.
const haLink = (target: string, network: string): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
// the same hue as the radio links behind this hub, so one transport
// reads as one colour all the way back to Home Assistant
color: style.getPropertyValue(networkToColorVar(network)),
type: "solid",
},
});
// HA reaches the mesh through the border routers and Wi-Fi access points
topology.nodes
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
.forEach((node) =>
links.push(haLink(node.id, node.kind === "wifi_ap" ? "wifi" : "thread"))
);
// keep the strongest link of every node in the force layout so
// nodes hang near their best connection instead of floating free
nodes.forEach((node) => {
let bestLink: NetworkLink | undefined;
const hasActiveLink = links.some((link) => {
if (link.source !== node.id && link.target !== node.id) {
return false;
}
if (!link.ignoreForceLayout) {
return true;
}
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
if (
linkValue >
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
) {
bestLink = link;
}
return false;
});
if (!hasActiveLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
});
return { nodes, links, categories };
}
@@ -0,0 +1,508 @@
import { mdiRefresh } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterTopologyDirectionInfo,
} from "../../../../../data/matter";
import {
fetchMatterNetworkTopology,
subscribeMatterNetworkTopology,
} from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
getTopologyNodeName,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
const UPDATE_THROTTLE_TIME = 5000;
@customElement("matter-network-visualization")
export class MatterNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _topology?: MatterNetworkTopology;
@state() private _notSupported = false;
@state() private _error?: string;
@state() private _refreshing = false;
@state() private _searchFilter = "";
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
(topology: MatterNetworkTopology) => {
this._topology = topology;
},
UPDATE_THROTTLE_TIME
);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !this._unsub) {
this._subscribe();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._throttledUpdateTopology.cancel();
if (this._unsub) {
this._unsub.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
}
}
private _subscribe(): void {
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
if (!this._topology) {
this._topology = topology;
} else {
this._throttledUpdateTopology(topology);
}
});
this._unsub.catch((err: { code?: string; message?: string }) => {
this._unsub = undefined;
if (err?.code === "not_supported" || err?.code === "unknown_command") {
this._notSupported = true;
} else {
this._error = err?.message || String(err);
}
});
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.visualization.header"
)}
back-path="/config/matter/dashboard"
>
${
this.narrow && this._topology?.nodes.length
? html`<div slot="header">${this._renderInputSearch()}</div>`
: nothing
}
${this._renderContent()}
</hass-subpage>
`;
}
private _renderContent() {
if (this._notSupported) {
return html`<div class="center">
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.visualization.not_supported"
)}
</ha-alert>
</div>`;
}
if (this._error) {
return html`<div class="center">
<ha-alert alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.visualization.error_loading",
{ error: this._error }
)}
</ha-alert>
</div>`;
}
if (!this._topology) {
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
}
if (!this._topology.nodes.length) {
return html`<div class="center empty">
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
</div>`;
}
return html`
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(
this._topology,
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class="refresh-button"
.disabled=${this._refreshing}
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.matter.visualization.refresh_topology"
)}
></ha-icon-button>
</ha-network-graph>
`;
}
private _renderInputSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._searchFilter}
@input=${this._handleSearchChange}
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private async _refreshTopology(): Promise<void> {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
this._topology = await fetchMatterNetworkTopology(this.hass, true);
} catch (err: unknown) {
this._error = (err as { message?: string })?.message || String(err);
} finally {
this._refreshing = false;
}
}
private _formatNetworkData = memoizeOne(
(
topology: MatterNetworkTopology,
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"],
// node/link colors and labels also depend on the theme and language,
// so both take part in the cache key even though they are read via hass
_themes: HomeAssistant["themes"],
_language: HomeAssistant["language"]
) => createMatterNetworkChartData(topology, this.hass, this)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getConnection(
source: string,
target: string
): MatterNetworkTopologyConnection | undefined {
return this._topology?.connections.find(
(conn) =>
(conn.source === source && conn.target === target) ||
(conn.source === target && conn.target === source)
);
}
private _getNodeName(id: string): string {
const node = this._getTopologyNode(id);
return node ? getTopologyNodeName(node, this.hass) : id;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
}
const attributes: string[] = [];
if (node.node_id != null) {
attributes.push(String(node.node_id));
}
if (node.network_name) {
attributes.push(node.network_name);
}
if (node.ext_address) {
attributes.push(node.ext_address);
}
if (node.vendor_name) {
attributes.push(node.vendor_name);
}
if (node.model_name) {
attributes.push(node.model_name);
}
if (node.host_name) {
attributes.push(node.host_name);
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
private _localizeDynamic(prefix: string, value: string): string {
return (
this.hass.localize(
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
) || value
);
}
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
const strength = this._localizeDynamic(
"visualization.strength",
direction.strength
);
if (direction.lqi != null) {
return `${strength} (LQI ${direction.lqi})`;
}
if (direction.rssi != null) {
return `${strength} (RSSI ${direction.rssi} dBm)`;
}
return strength;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
}
const lines: TemplateResult[] = [];
// the link color now encodes the transport, and the graph legend can
// only describe nodes, so name it here
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", conn.network)}`
);
if (conn.source_to_target) {
lines.push(
html`<br />${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}:
${this._formatDirection(conn.source_to_target)}`
);
}
if (conn.target_to_source) {
lines.push(
html`<br />${this._getNodeName(conn.target)}
${this._getNodeName(conn.source)}:
${this._formatDirection(conn.target_to_source)}`
);
}
if (!conn.source_to_target && !conn.target_to_source) {
// no per-direction reading: state the overall strength the width is
// drawn from, so this edge class is not left unexplained
const details = [
this._localizeDynamic("visualization.strength", conn.strength),
];
if (conn.via_route_table) {
details.push(
this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)
);
}
lines.push(html`<br />${details.join(" • ")}`);
}
return html`<b
>${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}</b
>${lines}`;
}
const { id } = data as { id: string };
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const lines: TemplateResult[] = [];
if (node.node_id != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.node_id"
)}:</b
>
${node.node_id}`
);
}
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", node.network_type)}${
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.role) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.role"
)}:</b
>
${this._localizeDynamic("visualization.roles", node.role)}`
);
}
if (node.available != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.status"
)}:</b
>
${this.hass.localize(
node.available
? "ui.panel.config.matter.visualization.online"
: "ui.panel.config.matter.visualization.offline"
)}`
);
}
if (device?.manufacturer || node.vendor_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device?.manufacturer || node.vendor_name}`
);
}
if (device?.model || node.model_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device?.model || node.model_name}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
if (node.last_seen != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.last_seen"
)}:</b
>
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
if (node?.ha_device_id) {
navigate(`/config/devices/device/${node.ha_device_id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--ha-space-4);
box-sizing: border-box;
}
ha-alert {
max-width: 500px;
}
.empty {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-network-visualization": MatterNetworkVisualization;
}
}
@@ -1,14 +1,27 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
mdiAlertCircleOutline,
mdiCheck,
mdiDevices,
mdiShape,
mdiTune,
} from "@mdi/js";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { Action } from "../../../../../data/script";
import { callExecuteScript } from "../../../../../data/service";
@@ -18,6 +31,10 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import "./mqtt-subscribe-card";
import { brandsUrl } from "../../../../../util/brands-url";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { mdiMqttLogo } from "../../../../../resources/mqtt-logo-svg";
const qosLevel = ["0", "1", "2"];
@@ -59,78 +76,240 @@ export class MQTTConfigPanel extends LitElement {
})
private _retain = false;
protected render(): TemplateResult {
@state() private _configEntry?: ConfigEntry;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfigEntry();
}
}
private _MQTTDeviceIds = memoizeOne(
(
devices: HomeAssistant["devices"],
configEntryId?: string
): Set<string> => {
if (!configEntryId) {
return new Set();
}
return new Set(
Object.values(devices)
.filter((device) => device.config_entries.includes(configEntryId))
.map((device) => device.id)
);
}
);
private _entityCount = memoizeOne(
(entities: HomeAssistant["entities"], deviceIds: Set<string>): number =>
Object.values(entities).filter(
(entity) => entity.device_id && deviceIds.has(entity.device_id)
).length
);
protected render(): TemplateResult | typeof nothing {
if (!this._configEntry) {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
const deviceIds = this._MQTTDeviceIds(
this.hass.devices,
this._configEntry.entry_id
);
const entityCount = this._entityCount(this.hass.entities, deviceIds);
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="MQTT"
back-path="/config/integrations/integration/mqtt"
has-fab
>
<div class="content">
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.mqtt.option_flow"
)}</ha-button
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize(
"ui.panel.config.mqtt.publish"
)}</ha-button
>
</div>
</ha-card>
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
<div class="container">
${this._renderNetworkStatus(isOnline, deviceIds.size)}
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
${this._renderNavigationCard()} ${this._renderPublishCard()}
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
</div>
</div>
</hass-subpage>
`;
}
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
return html`
<ha-card class="content network-status">
<div class="card-content">
<div class="heading">
<div class="icon ${isOnline ? "success" : "error"}">
<ha-svg-icon
.path=${isOnline ? mdiCheck : mdiAlertCircleOutline}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.mqtt.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize("ui.panel.config.mqtt.devices", {
count: deviceCount,
})}
</small>
</div>
<img
class="logo"
alt="MQTT"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: "mqtt",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
</div>
</div>
</ha-card>
`;
}
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.mqtt.my_network_title")}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.device_count", {
count: deviceCount,
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.entity_count", {
count: entityCount,
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderNavigationCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" @click=${this._openOptionFlow}>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.option_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.option_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" @click=${this._openConfigFlow}>
<ha-svg-icon slot="start" .path=${mdiMqttLogo}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.config_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.config_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderPublishCard() {
return html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize("ui.panel.config.mqtt.publish")}</ha-button
>
</div>
</ha-card>
`;
}
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
});
this._configEntry = configEntries.find(
(entry) => entry.disabled_by === null && entry.source !== "ignore"
);
}
private _handleTopic(ev: InputEvent) {
this._topic = (ev.target as HTMLInputElement).value;
}
@@ -175,19 +354,23 @@ export class MQTTConfigPanel extends LitElement {
}
private async _openOptionFlow() {
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has("config_entry")) {
showOptionsFlowDialog(this, this._configEntry!);
}
private _openConfigFlow = async () => {
if (!this._configEntry) {
return;
}
const configEntryId = searchParams.get("config_entry") as string;
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
showConfigFlowDialog(this, {
startFlowHandler: this._configEntry.domain,
manifest: await fetchIntegrationManifest(
this.hass,
this._configEntry.domain
),
entryId: this._configEntry.entry_id,
navigateToResult: true,
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
};
static get styles(): CSSResultGroup {
return [
@@ -199,17 +382,37 @@ export class MQTTConfigPanel extends LitElement {
-moz-user-select: initial;
}
.content {
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
.nav-card {
overflow: hidden;
}
.nav-card .card-content {
padding: 0;
}
.nav-card .card-header {
padding-bottom: var(--ha-space-2);
}
.content {
margin-top: var(--ha-space-6);
}
.panel-dev-mqtt-fields {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-select {
width: 96px;
margin: 0 8px;
@@ -236,6 +439,78 @@ export class MQTTConfigPanel extends LitElement {
display: block;
margin: 16px auto;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .logo {
height: 40px;
width: 40px;
margin-inline-start: auto;
object-fit: contain;
}
.network-status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.network-status div.heading .icon.success {
--icon-color: var(--success-color);
}
.network-status div.heading .icon.error {
--icon-color: var(--error-color);
}
.network-status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.network-status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: 24px;
height: 24px;
}
.network-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);
}
.network-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);
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
}
a[slot="fab"] {
text-decoration: none;
}
`,
];
}
@@ -1,15 +1,20 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiContentCopy } from "@mdi/js";
import { formatTime } from "../../../../../common/datetime/format_time";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-markdown";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { MQTTMessage } from "../../../../../data/mqtt";
import { subscribeMQTTTopic } from "../../../../../data/mqtt";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-formfield";
@@ -68,53 +73,56 @@ class MqttSubscribeCard extends LitElement {
return html`
<ha-card
header=${this.hass.localize("ui.panel.config.mqtt.description_listen")}
class="content_subscribe_panel"
>
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
<div class="card-content">
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
</div>
<div class="events">
${this._messages.map(
(msg) => html`
@@ -128,7 +136,17 @@ class MqttSubscribeCard extends LitElement {
this.hass!.config
),
})}
<pre>${msg.payload}</pre>
<div class="code-block">
<ha-icon-button
class="copy-button"
.path=${mdiContentCopy}
@click=${this._handleCopyClick}
data-payload=${msg.payload}
></ha-icon-button>
<ha-markdown
.content=${`\`\`\`${this._json_format ? "json" : ""}\n${msg.payload}\n\`\`\``}
></ha-markdown>
</div>
<div class="bottom">
QoS: ${msg.message.qos} - Retain:
${Boolean(msg.message.retain)}
@@ -156,6 +174,16 @@ class MqttSubscribeCard extends LitElement {
this._json_format = (ev.target! as any).checked;
}
private async _handleCopyClick(ev: Event): Promise<void> {
const payload = (ev.target as HTMLElement).getAttribute("data-payload");
if (payload) {
await copyToClipboard(payload);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
}
private async _handleSubmit(): Promise<void> {
if (this._subscribed) {
this._subscribed();
@@ -199,6 +227,12 @@ class MqttSubscribeCard extends LitElement {
padding: var(--ha-space-4);
padding-bottom: var(--ha-space-8);
}
.content_subscribe_panel {
margin-top: var(--ha-space-6);
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
.events {
margin: -16px 0;
padding: 0 16px;
@@ -230,6 +264,20 @@ class MqttSubscribeCard extends LitElement {
ha-input {
flex: 1;
}
.code-block {
position: relative;
margin-bottom: 16px;
}
.code-block ha-markdown {
padding-right: 40px;
}
.copy-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
color: var(--secondary-text-color);
}
@media screen and (max-width: 600px) {
ha-select {
display: block;
@@ -2,6 +2,7 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -19,11 +20,13 @@ import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
RssiError,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
subscribeZwaveNodeStatistics,
} from "../../../../../data/zwave_js";
@@ -54,19 +57,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _searchFilter = "";
public hassSubscribe() {
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
);
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
return devices.map((device) =>
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
const nodeId = message.nodeId ?? message.node_id;
this._devices[nodeId!] = device;
this._nodeStatistics[nodeId!] = message;
this._handleUpdatedNodeStatistics();
})
);
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
const nodeIdsByDeviceId: Record<string, number> = {};
Object.values(this.hass.devices).forEach((device) => {
if (!device.config_entries.includes(this.configEntryId)) {
return;
}
const nodeId = getNodeIdFromDevice(device);
if (nodeId === undefined) {
return;
}
devices[nodeId] = device;
nodeIdsByDeviceId[device.id] = nodeId;
subscriptions.push(
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
this._nodeStatistics[nodeId] = message;
this._handleUpdatedNodeStatistics();
})
);
});
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
this._devices = devices;
return subscriptions;
}
public connectedCallback() {
@@ -164,8 +184,10 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
const route = stats?.lwr || stats?.nlwr;
return html`${sourceName}
${targetName}${
route?.protocol_data_rate
@@ -333,55 +355,69 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
if (controllerNode === undefined) {
return { nodes, links, categories };
}
const controllerId = String(controllerNode);
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
const route = stats.lwr || stats.nlwr;
if (route) {
const hops = [
...route.repeaters.map((id, i) => [
Object.keys(this._devices).find(
(_nodeId) => this._devices[_nodeId]?.id === id
)?.[0],
route.repeater_rssi[i],
]),
[controllerNode!, route.rssi],
];
let sourceNode: string = nodeId;
hops.forEach(([repeater, rssi]) => {
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) =>
link.source === sourceNode && link.target === String(repeater)
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target: String(repeater),
value: RSSI,
lineStyle: {
width,
color:
repeater === controllerNode
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = String(repeater);
});
if (!route) {
return;
}
// Routes go from the controller to the node via the repeaters, in order.
// Each station measures the hop leaving it: the controller reports
// `rssi`, repeater i reports `repeater_rssi[i]`.
const hops: [string, RssiError | number | null][] = [];
let hopRssi = route.rssi;
route.repeaters.forEach((deviceId, i) => {
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
// skip repeaters we can't resolve, so the chain stays connected
if (repeaterNodeId !== undefined) {
hops.push([String(repeaterNodeId), hopRssi]);
}
hopRssi = route.repeater_rssi[i];
});
hops.push([nodeId, hopRssi]);
let sourceNode = controllerId;
hops.forEach(([target, rssi]) => {
if (target === sourceNode) {
return;
}
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) => link.source === sourceNode && link.target === target
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target,
value: RSSI,
lineStyle: {
width,
color:
sourceNode === controllerId
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = target;
});
});
return { nodes, links, categories };
@@ -1,29 +1,46 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
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 {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -38,39 +55,59 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
@state() private _submitting = false;
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: suggestions?.icon,
title: suggestions?.title ?? "",
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
}
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
}
public closeDialog(): void {
public closeDialog() {
this._open = false;
}
private _dialogClosed(): void {
private _dialogClosed() {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -82,6 +119,18 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -96,41 +145,43 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
</div>
<ha-dialog-footer slot="footer">
${
@@ -185,6 +236,46 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
${
this._error?.base
? html`<ha-alert alert-type="error">${this._error.base}</ha-alert>`
: nothing
}
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -251,15 +342,16 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
)
: "";
private _valueChanged(ev: CustomEvent) {
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
this._error = undefined;
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
if (ev.detail.value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -268,13 +360,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
};
}
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
} else {
this._data = value;
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -286,36 +395,55 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
: baseSlug,
};
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private async _updateDashboard() {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -339,10 +467,25 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
if (await this._params.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -351,7 +494,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
static get styles(): CSSResultGroup {
return [haStyleDialog, css``];
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
}
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -17,6 +18,8 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>

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