Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld f1171afd0b Send normalized duration for timer presets
Presets are parsed leniently by createDurationData like other duration
inputs, but the raw config value was sent to timer.start. A malformed
preset such as 1:nope:00 rendered as 1 h yet was rejected by core. Store
the normalized seconds so the label and the service call always match.
2026-08-26 13:47:35 +02:00
Maarten LakerveldandCopilot Autofix powered by AI b045bd903a Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-26 13:47:16 +02:00
Maarten Lakerveld cac23916b4 Pulse timer red when it finishes
When a timer runs out or timer.finish is called, the tile icon and the
more-info countdown pulse red twice. Cancelling does not pulse: the new
timerJustFinished helper distinguishes the transitions via the
last_transition attribute. Honors prefers-reduced-motion.
2026-08-26 09:57:09 +02:00
Maarten Lakerveld 8562e068ea Add timer-actions and timer-presets card features
timer-actions shows start/pause/cancel/finish buttons (finish opt-in)
with state-aware disabling; start becomes restart while active.
timer-presets shows configurable one-tap durations that call timer.start.
Timer tile suggestions default to the timer-actions feature.
2026-08-26 09:57:09 +02:00
Maarten Lakerveld 0aaf64ae07 Modernize timer more-info dialog with state header and live countdown
Use ha-more-info-state-header with a ticking remaining-time display and
the standard more-info control layout. Adds timer to
DOMAINS_WITH_NEW_MORE_INFO, replacing the legacy state-card row.
2026-08-26 09:57:08 +02:00
Maarten Lakerveld a9239b123e Add timer data helpers for formatter-based display and duration serialization
Refactor computeDisplayTimer to take formatEntityState instead of hass so
context-migrated components can reuse it, add finishes_at to TimerEntity,
and add durationDataToTimerString for serializing duration input values.
2026-08-26 09:57:08 +02:00
Paul BotteinandGitHub fb35194041 Share favorites editor and security entity filter (#53785) 2026-08-26 09:11:48 +03:00
Paul BotteinandGitHub d4e3ec858e Remove pulse from security dashboard alerts (#53784) 2026-08-26 08:24:31 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6516b8de68 Update dependency @bundle-stats/plugin-webpack-filter to v4.22.3 (#53783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 18:10:10 +02:00
Paul BotteinandGitHub edb2f98d03 Show targets in automation traces (#53718) 2026-08-25 18:50:04 +03:00
Aidan TimsonandGitHub 5ae95a1ea2 Add favorites to Security dashboard (#53512)
* Add favorites to security dashboard

* Use contexts in favorites editor

* Update src/translations/en.json

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

* Address security favorites review

---------

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

* Fix security alerts editor and card defaults

* Use consistent security editor icons

* Fix security dashboard alert state

* Fix lint

* Render security alerts as individual cards

* Use context data in security alerts editor

* Add pulse for alerts

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

* Use explicit cover and lock alert states

* Fix test

* Remove security panel rendering tests

* Use complete states

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

* Remove camera

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

* open state only

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

* Remove security entity filter cache

* Format

* Update security alert visibility tests

---------

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

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

* Updated constants

* Fix typo

* Fix name

* Restore part original generated script comment

* Add convertible units and fix null type

* Fix type

* Update constants

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

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

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

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

* Wait for lazy selector upgrades before nested form focus.

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

* Fix security alert cards

* Validate security alert card configuration

* Keep a single generic alert card

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

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

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


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

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

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

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

* Catch up the energy live day before subscribe fetches.

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

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

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

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

* Fix nested form dialog close and focus restoration

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

Add regression coverage for both behaviors.

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

* Arrange into sections

* Clean up UI

* Add descriptions

* Align with Core changes

* Address review feedback and add an info dialog

* Combine websocket commands into one

* Drop unnecessary info dialog rows

* Add a status bar

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


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

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

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

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

* Add button entity

* Add to button entities

* add to tile icon

* Delay for spinner

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

* Mark charts as busy while the sonification chunk loads

* Skip chart sonification when there is nothing to navigate

* Drop the direct chart2music dependency

* Exclude legend-hidden series when judging chart sonifiability

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

* suggested changes

* Repair Flow Next Flow Repair

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

* Add support for the next_flow being another repair flow

* fix lint errors

* Reviewer response and fix linter error

* Revert to generics to avoid multiple casts

* Move show-dialog-repair-flows to dialogs

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

* Apply suggestions from code review

* Apply suggestion from @MindFreeze

* format

---------

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

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

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

* Vertically align ha-bar

---------

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

* add results of bundle changes

* update browserlist, budgets and ecma terser rewrite level

* remove results.md used for testing

* Apply suggestion from @silamon

---------

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

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

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

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

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

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

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 16:18:40 +03:00
171 changed files with 10845 additions and 3524 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- Each test added by the change protects real logic, not the look of a component.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
+6 -5
View File
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
## When To Add Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
+40 -24
View File
@@ -1,36 +1,52 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - released in the last 2 years + current alpha/beta versions
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 1 year
Firefox ESR
>= 0.5%
last 2 years
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05%
not dead
not op_mini all
# Legacy builds are served when modern requirements are not met.
# Floors are pinned explicitly (not usage-based) so the support policy is
# deliberate and does not drift with global usage statistics, which do not
# represent old tablets and wall displays used as Home Assistant dashboards:
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
# also covers old kiosk browsers and no-longer-updating webviews above it.
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
# enterprise installs, whose engines can run the legacy build fine.
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
# If Firefox ever becomes the pin forcing extra output, raise it first.
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
Chrome >= 59
ChromeAndroid >= 59
Edge >= 79
Firefox >= 94
FirefoxAndroid >= 94
iOS >= 12
Safari >= 12
Samsung >= 9
[legacy-sw]
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
Chrome >= 59 and supports serviceworkers
ChromeAndroid >= 59 and supports serviceworkers
Edge >= 79 and supports serviceworkers
Firefox >= 94 and supports serviceworkers
FirefoxAndroid >= 94 and supports serviceworkers
iOS >= 12 and supports serviceworkers
Safari >= 12 and supports serviceworkers
Samsung >= 9 and supports serviceworkers
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
@@ -0,0 +1,47 @@
name: Sync device class constants
# Mirrors the device class constants for Home Assistant Core's into the
# build-time default in src/data/devce_classes.ts and opens a PR
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # Daily, 04:00 UTC
permissions:
contents: read
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/sync-numeric-device-classes
commit-message: Update numeric sensor device classes
title: Update numeric sensor device classes
body: |
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
`SensorDeviceClass`.
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
@@ -1,7 +1,8 @@
name: Sync numeric device classes
name: Sync sensor entity constants
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
# units and related device and state classes arrays into the
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
# when it drifts. Reads homeassistant/generated/sensor.json from core.
on:
@@ -28,8 +29,8 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
+1
View File
@@ -33,6 +33,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
## Project Skills
+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": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
"app": 576583,
"core": 54790,
"authorize": 543547,
"onboarding": 655556
},
"frontend-legacy": {
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
"app": 717124,
"core": 181583,
"authorize": 699175,
"onboarding": 816133
}
}
+4 -2
View File
@@ -63,8 +63,10 @@ module.exports.htmlMinifierOptions = {
};
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
safari10: !latestBuild,
ecma: latestBuild ? 2015 : 5,
// Highest syntax the minifier may emit; it never downlevels. Every browser
// in [modern] is well past ES2020 (universal since spring 2020); the
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
ecma: latestBuild ? 2020 : 2017,
module: latestBuild,
format: { comments: false },
sourceMap: !isTestBuild,
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
gulp.task("gen-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const domainDeviceClasses = data ?? {};
if (!Object.keys(domainDeviceClasses).length) {
throw new Error(`No device classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with \`script/gen_device_classes\`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
${Object.entries(domainDeviceClasses)
.map(
([domain, deviceClasses]) =>
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
@@ -0,0 +1,83 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_entity_constants.ts"
);
gulp.task("gen-sensor-entity-constants", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
const deviceClassUnits = data.device_class_units ?? {};
const convertibleClassUnits = data.convertible_units ?? {};
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
!stateClasses.length ||
!Object.keys(deviceClassUnits).length ||
!Object.keys(stateClassUnits).length
) {
throw new Error(
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
);
}
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_sensor_entity_constants\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
${Object.entries(deviceClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
${Object.entries(convertibleClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
+2 -1
View File
@@ -9,8 +9,9 @@ import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-numeric-device-classes.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./rspack.js";
@@ -25,6 +25,9 @@ title: Button
<ha-button appearance="filled">
filled button
</ha-button>
<ha-button appearance="outlined">
outlined button
</ha-button>
<ha-button size="s">
small
@@ -65,7 +68,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| Name | Type | Default | Description |
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
+1 -1
View File
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const appearances = ["accent", "filled", "plain"];
const appearances = ["accent", "filled", "outlined", "plain"];
const variants = ["brand", "danger", "neutral", "warning", "success"];
@customElement("demo-components-ha-button")
+125
View File
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
interface TreeChild {
key: string;
label: string;
}
interface TreeGroup {
key: string;
label: string;
children: TreeChild[];
}
const treeGroups: TreeGroup[] = [
{
key: "binary_sensor",
label: "Binary sensor",
children: [
{ key: "door", label: "Door" },
{ key: "motion", label: "Motion" },
{ key: "window", label: "Window" },
],
},
{
key: "cover",
label: "Cover",
children: [
{ key: "garage", label: "Garage" },
{ key: "shutter", label: "Shutter" },
],
},
];
interface TreeRow {
group: TreeGroup;
child?: TreeChild;
}
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
{ group },
...group.children.map((child) => ({ group, child })),
]);
const treeKey = (group: TreeGroup, child: TreeChild) =>
`${group.key}/${child.key}`;
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
@state() private _multiCheckEnd: number | Set<number> = new Set();
@state() private _tree = new Set<string>();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Controlled selection with indeterminate groups">
<ha-list-selectable
multi
controlled
aria-label="Controlled tree"
@ha-list-item-selected=${this._onTreeToggle}
@ha-list-item-deselected=${this._onTreeToggle}
>
${treeGroups.map((group) => {
const groupState = this._groupState(group);
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group.key}
?selected=${groupState === "all"}
?indeterminate=${groupState === "some"}
>
<span slot="headline">${group.label}</span>
</ha-list-item-option>
${group.children.map(
(child) => html`
<ha-list-item-option
class="child"
appearance="checkbox"
selection-position="end"
.value=${treeKey(group, child)}
?selected=${this._tree.has(treeKey(group, child))}
>
<span slot="headline">${child.label}</span>
</ha-list-item-option>
`
)}
`;
})}
</ha-list-selectable>
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return next;
}
private _groupState(group: TreeGroup): "none" | "some" | "all" {
const selected = group.children.filter((child) =>
this._tree.has(treeKey(group, child))
).length;
if (selected === 0) {
return "none";
}
return selected === group.children.length ? "all" : "some";
}
private _onTreeToggle = (ev: CustomEvent<number>) => {
const row = treeRows[ev.detail];
if (!row) {
return;
}
const next = new Set(this._tree);
if (row.child) {
const key = treeKey(row.group, row.child);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
} else {
const select = this._groupState(row.group) !== "all";
row.group.children.forEach((child) => {
const key = treeKey(row.group, child);
if (select) {
next.add(key);
} else {
next.delete(key);
}
});
}
this._tree = next;
};
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
.drag-handle {
cursor: grab;
}
.child::part(base) {
padding-inline-start: var(--ha-space-12);
}
`;
}
+19
View File
@@ -16,6 +16,25 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
+16 -13
View File
@@ -42,14 +42,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.4",
"@codemirror/commands": "6.11.0",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.8",
"@codemirror/view": "6.43.9",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
@@ -101,12 +101,13 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.7.1",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"intl-messageformat": "11.2.14",
"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",
@@ -114,7 +115,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -142,18 +143,18 @@
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -164,17 +165,18 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.4",
"@types/luxon": "3.7.5",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.10",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -195,6 +197,7 @@
"jsdom": "30.0.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
@@ -212,7 +215,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -8,4 +8,4 @@ set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
./node_modules/.bin/gulp gen-device-classes
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-sensor-entity-constants
@@ -0,0 +1,217 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish(animation);
this._animation = animation;
}
private async _finish(animation?: Animation) {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
await this.host.updateComplete;
}
if (this._animation !== animation) {
return;
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
@@ -0,0 +1,70 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+4 -1
View File
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
};
export const generateEntityFilter = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
+3
View File
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
cover: {
speed: "supported_speeds",
},
event: {
event_type: "event_types",
},
+10 -7
View File
@@ -55,17 +55,20 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
}
const resultPromise = func(hass, ...args);
anyHass[cacheKey] = resultPromise;
const cachePromise = resultPromise.then((result) => ({
result,
cacheKey: generateCacheKey?.(hass, result),
}));
anyHass[cacheKey] = cachePromise;
resultPromise.then(
cachePromise.then(
// When successful, set timer to clear cache
(result) => {
anyHass[cacheKey] = {
result,
cacheKey: generateCacheKey?.(hass, result),
};
anyHass[cacheKey] = result;
setTimeout(() => {
anyHass[cacheKey] = undefined;
if (anyHass[cacheKey] === result) {
anyHass[cacheKey] = undefined;
}
}, cacheTime);
},
// On failure, clear cache right away
+355
View File
@@ -0,0 +1,355 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Fewer than this and there is nothing to walk between, so a focus stop would
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
const itemValues = (raw: unknown): unknown[] | null => {
if (Array.isArray(raw)) {
return raw;
}
if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
if (Array.isArray(value)) {
return value;
}
}
return null;
};
// ECharts spells empty values and numbers as strings too, and neither names a
// category.
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
const isCategoryKey = (value: unknown): boolean =>
typeof value === "string" &&
!NON_CATEGORY_STRINGS.has(value) &&
Number.isNaN(Number(value));
// A chart with the value axis on x, like the energy device charts, encodes its
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
// that way when every item has that shape, so mirror the same gate.
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
data.length > 0 &&
data.every((raw) => {
const values = itemValues(raw);
return (
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
);
});
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
// (or [y, category] in a value-first series) and drops anything whose y is not
// a real number, which rejects gap-only series. Counts no further than `limit`
// so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
const valueFirst = isValueFirstSeries(data);
let found = 0;
for (const raw of data) {
let y: unknown = raw;
const values = itemValues(raw);
if (values) {
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
} else if (raw && typeof raw === "object") {
y = (raw as { value?: unknown }).value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
if (found >= limit) {
break;
}
}
}
return found;
};
const countNavigablePoints = (
series: readonly ({ data?: unknown } | undefined)[]
): number => {
let total = 0;
for (const s of series) {
total += countNumericPoints(s?.data, MIN_NAVIGABLE_POINTS - total);
if (total >= MIN_NAVIGABLE_POINTS) {
break;
}
}
return total;
};
export const canSonifyChart = (
data: HaECSeries,
// Legend-hidden series reach ECharts with their data stripped, so they cannot
// be sonified either.
hiddenDatasets?: ReadonlySet<string>
): boolean => {
const series = ensureArray(data);
const visible = hiddenDatasets?.size
? series.filter((s) => !hiddenDatasets.has(String(s.id ?? s.name)))
: series;
return (
// Cards commonly push empty placeholder series, so judge the chart by the
// points the extension can actually read — but every type has to be
// convertible too.
countNavigablePoints(visible) >= MIN_NAVIGABLE_POINTS &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
// Maps a category key or item name to what should be announced for it, so
// cards that key their data on ids (like the energy device charts) can have
// the display names read out instead. Returning undefined keeps the original.
formatLabel?: (label: string) => string | undefined;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
// Rebuilds the labels the extension would announce — the category axis's data,
// or the item names on pies, which ignore whatever vestigial axes the chart
// options carry — with each one run through the card's formatter. Returns
// undefined when there is nothing to reword, so the extension's own labels
// stay untouched.
const buildValueLabels = (
categoryAxis: { type?: string; data?: unknown } | undefined,
firstSeries: { type?: string; data?: unknown } | undefined,
formatLabel?: (label: string) => string | undefined
): string[] | undefined => {
if (!formatLabel) {
return undefined;
}
const axisData =
categoryAxis?.type === "category" &&
Array.isArray(categoryAxis.data) &&
categoryAxis.data.length
? categoryAxis.data
: undefined;
const labels = axisData
? axisData.map((entry) =>
entry && typeof entry === "object"
? String((entry as { value?: unknown }).value ?? "")
: String(entry ?? "")
)
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
? firstSeries.data.map((raw) => {
const name = (raw as { name?: unknown } | null)?.name;
if (typeof name === "string") {
return name;
}
const values = itemValues(raw);
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
})
: undefined;
if (!labels?.length || labels.every((label) => !label)) {
return undefined;
}
return labels.map((label) => formatLabel(label) ?? label);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying points it can read.
const allSeries = ensureArray(chartOptions.series) as (
HaECSeriesItem | undefined
)[];
const readable = allSeries.filter((s) => countNumericPoints(s?.data, 1));
// A single point is not navigable, so it does not earn a focus stop either.
if (countNavigablePoints(readable) < MIN_NAVIGABLE_POINTS) {
return null;
}
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly. On a
// horizontal chart the announced x is the category from the y axis and the
// announced y is the value from the x axis, so the sources swap.
const isTimeAxis = xAxis?.type === "time";
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
const valueLabels = buildValueLabels(
isHorizontal ? yAxis : xAxis,
readable[0],
options.formatLabel
);
const x = {
label:
(isHorizontal ? yAxis?.name : xAxis?.name) ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
...(valueLabels ? { valueLabels } : {}),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label:
(isHorizontal ? xAxis?.name : yAxis?.name) ||
localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err) {
options.onError(err instanceof Error ? err.message : String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+126 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -114,6 +117,11 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string;
// Lets cards that key their data on ids have display names announced
// instead when the chart is navigated with Chart2Music.
@property({ attribute: false })
public sonificationLabelFormatter?: (label: string) => string | undefined;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@@ -146,6 +154,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
@state() private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +217,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +332,18 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") ||
!canSonifyChart(this.data, this._hiddenDatasets))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +369,9 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable &&
canSonifyChart(this.data, this._hiddenDatasets);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +383,23 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
aria-busy=${ifDefined(this._sonificationLoading ? "true" : undefined)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +571,61 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
// Dropping tabindex off the active element resets focus to the document and
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
formatLabel: this.sonificationLabelFormatter,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +638,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1558,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+10 -7
View File
@@ -163,12 +163,15 @@ export function generateStatisticsChartData(
return;
}
const isLineChart = chartType === "line";
// Points carry their time as epoch milliseconds, not Date objects:
// ECharts accepts both, but Chart2Music only reads a numeric x, and a
// Date would make it announce points by index instead of time.
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
? (start.getTime() + end.getTime()) / 2
: start.getTime();
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
@@ -182,10 +185,10 @@ export function generateStatisticsChartData(
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
d.data!.push([prevEndTime!.getTime(), ...prevValues![i]!]);
d.data!.push([prevEndTime!.getTime(), null]);
}
d.data!.push([start, ...dataValue!]);
d.data!.push([start.getTime(), ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValue[dataValue.length - 1]);
@@ -387,7 +390,7 @@ export function generateStatisticsChartData(
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
d.data!.push([lastEndTime.getTime(), ...lastValues[i]!]);
});
}
@@ -423,7 +426,7 @@ export function generateStatisticsChartData(
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
statDataSets[i].data!.push([now.getTime(), ...val]);
trackY(val[val.length - 1]);
});
}
@@ -1,34 +1,57 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import type { HomeAssistant } from "../../../types";
import { customElement, property, state } from "lit/decorators";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../common/dom/fire_event";
import "./state-badge";
import "../ha-icon-button";
import "../ha-settings-row";
import {
internationalizationContext,
registriesContext,
} from "../../data/context";
declare global {
interface HASSDomEvents {
"delete-favorite-entity": { index: number };
}
interface HTMLElementTagNameMap {
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
}
}
@customElement("home-favorite-entity-list-item")
export class HomeFavoriteEntityListItem extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@customElement("ha-favorite-entity-list-item")
export class HaFavoriteEntityListItem extends LitElement {
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ type: Number }) public index = 0;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
const stateObj = this.hass.states[this.entityId];
const stateObj = this._stateObj;
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
? computeEntityPickerDisplay(
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
)
: { primary: this.entityId, secondary: undefined };
return html`
@@ -42,7 +65,7 @@ export class HomeFavoriteEntityListItem extends LitElement {
}
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.delete")}
.label=${this._i18n.localize("ui.common.delete")}
@click=${this._delete}
></ha-icon-button>
</ha-settings-row>
@@ -2,24 +2,28 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./home-favorite-entity-list-item";
@customElement("home-favorites-editor")
export class HomeFavoritesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaEntityPicker } from "./ha-entity-picker";
import "./ha-entity-picker";
import "../ha-sortable";
import "../ha-svg-icon";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { ValueChangedEvent } from "../../types";
import "./ha-favorite-entity-list-item";
@customElement("ha-favorites-editor")
export class HaFavoritesEditor extends LitElement {
@property({ attribute: false }) public favorites: string[] = [];
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
protected render() {
return html`
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
@@ -27,22 +31,21 @@ export class HomeFavoritesEditor extends LitElement {
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
}
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="home-list">
<div class="favorites-list">
${repeat(
this.favorites,
(entityId) => entityId,
(entityId, index) => html`
<div class="home-list-item favorite-row">
<div class="favorite-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<home-favorite-entity-list-item
<ha-favorite-entity-list-item
class="favorite-content"
.hass=${this.hass}
.entityId=${entityId}
.index=${index}
@delete-favorite-entity=${this._remove}
></home-favorite-entity-list-item>
></ha-favorite-entity-list-item>
</div>
`
)}
@@ -50,10 +53,9 @@ export class HomeFavoritesEditor extends LitElement {
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
.addButtonLabel=${this.addButtonLabel}
.excludeEntities=${this.favorites}
.entityFilter=${this.entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
@@ -102,7 +104,7 @@ export class HomeFavoritesEditor extends LitElement {
color: var(--secondary-text-color);
font-size: 12px;
}
.home-list {
.favorites-list {
display: flex;
flex-direction: column;
}
@@ -131,6 +133,6 @@ export class HomeFavoritesEditor extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"home-favorites-editor": HomeFavoritesEditor;
"ha-favorites-editor": HaFavoritesEditor;
}
}
+141
View File
@@ -0,0 +1,141 @@
import { mdiAlertOctagram, mdiCheckBold } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "./ha-spinner";
import "./ha-svg-icon";
type ActionResult = "success" | "error";
// Keep in sync with ha-progress-button
const RESULT_DURATION = 2000;
// Long enough that fast actions go straight to their result without a flash
const SPINNER_DELAY = 150;
/**
* Home Assistant action result component
*
* @element ha-action-result
*
* @summary
* Wraps the content of an action trigger, for example an `ha-control-button`,
* and swaps it for a spinner while a slow action runs and for a success or
* error icon once it settles.
*
* @slot - Content of the trigger.
*/
@customElement("ha-action-result")
export class HaActionResult extends LitElement {
@state() private _loading = false;
@state() private _showSpinner = false;
@state() private _result?: ActionResult;
private _timeout?: number;
private _spinnerTimeout?: number;
public get busy(): boolean {
return this._loading;
}
public async run(action: Promise<unknown>): Promise<void> {
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._result = undefined;
this._loading = true;
this._spinnerTimeout = window.setTimeout(() => {
this._showSpinner = true;
}, SPINNER_DELAY);
try {
await action;
this._result = "success";
} catch (_err) {
this._result = "error";
} finally {
clearTimeout(this._spinnerTimeout);
this._loading = false;
this._showSpinner = false;
this._timeout = window.setTimeout(() => {
this._result = undefined;
}, RESULT_DURATION);
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._showSpinner = false;
this._result = undefined;
}
protected render() {
const busy = this._showSpinner || this._result !== undefined;
return html`
<span class="content ${busy ? "hidden" : ""}"><slot></slot></span>
${
busy
? html`<div class="indicator">${this._renderIndicator()}</div>`
: nothing
}
`;
}
private _renderIndicator() {
if (!this._result) {
return html`<ha-spinner></ha-spinner>`;
}
return html`
<ha-svg-icon
class=${this._result}
.path=${this._result === "success" ? mdiCheckBold : mdiAlertOctagram}
></ha-svg-icon>
`;
}
static styles = css`
/* Prefer no box so the host inherits the layout of the slot it sits in.
A ::slotted() rule in the host component can still override this. */
:host {
display: contents;
}
.content {
transition: opacity var(--ha-animation-duration-instant) ease-in-out;
}
.content.hidden {
opacity: 0;
}
.indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: fade-in var(--ha-animation-duration-instant) ease-in-out;
}
ha-spinner {
--ha-spinner-size: var(--mdc-icon-size, 24px);
--track-width: 2px;
}
/* Overshoot so the icon lands with a small pop */
ha-svg-icon {
animation: scale var(--ha-animation-duration-fast)
cubic-bezier(0.34, 1.56, 0.64, 1);
}
ha-svg-icon.success {
color: var(--ha-color-on-success-quiet);
}
ha-svg-icon.error {
color: var(--ha-color-on-danger-quiet);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-action-result": HaActionResult;
}
}
+63 -9
View File
@@ -12,6 +12,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
@@ -24,6 +25,7 @@ import {
import {
configContext,
connectionContext,
internationalizationContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
@@ -33,8 +35,13 @@ import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
@@ -67,6 +74,17 @@ export const assistPipelineChanged = (
current: AssistPipeline | undefined
): boolean => previous?.id !== current?.id;
export const greetingTranslationLanguage = (
pipelineLanguage: string | undefined,
interfaceLanguage: string | undefined
): string | undefined => {
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
return undefined;
}
const language = findAvailableLanguage(pipelineLanguage);
return language && language !== interfaceLanguage ? language : undefined;
};
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public pipeline?: AssistPipeline;
@@ -101,6 +119,13 @@ export class HaAssistChat extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, string>({
transformer: ({ language }) => language,
})
private _language!: string;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@@ -115,6 +140,8 @@ export class HaAssistChat extends LitElement {
private _conversationId: string | null = null;
private _greetingLoadToken = 0;
private _initialPromptSubmitted = false;
private _audioRecorder?: AudioRecorder;
@@ -131,17 +158,44 @@ export class HaAssistChat extends LitElement {
(changedProperties.has("pipeline") &&
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
) {
this._conversation = [
{
who: "hass",
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
];
this._conversation = [];
this._loadGreeting();
}
}
private async _loadGreeting(): Promise<void> {
const token = ++this._greetingLoadToken;
const language = greetingTranslationLanguage(
this.pipeline?.language,
this._language
);
let greeting: string | undefined;
if (language) {
try {
const result = await getTranslation(null, language, false);
if (result.language === language) {
greeting = result.data["ui.dialogs.voice_command.how_can_i_help"];
}
} catch (_err) {
// Translation failed to load; fall back to the interface language.
}
}
if (token !== this._greetingLoadToken) {
// The pipeline changed while loading; a newer load owns the greeting.
return;
}
this._conversation = [
{
who: "hass",
text:
greeting || this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
...this._conversation,
];
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
if (
@@ -157,7 +211,7 @@ export class HaAssistChat extends LitElement {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_conversation")) {
if (changedProps.has("_conversation") && this._conversation.length) {
this._scrollMessagesBottom();
}
if (
-161
View File
@@ -1,161 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../data/entity/entity_attributes";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-attribute-value";
import "./ha-expansion-panel";
@customElement("ha-attributes")
class HaAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: "extra-filters" }) public extraFilters?: string;
@state() private _expanded = false;
private get _filteredAttributes() {
return this._computeDisplayAttributes(
STATE_ATTRIBUTES.concat(
this.extraFilters ? this.extraFilters.split(",") : [],
(this.stateObj &&
STATE_ATTRIBUTES_DOMAIN_CLASS[computeStateDomain(this.stateObj)]?.[
this.stateObj.attributes?.device_class
]) ||
[]
)
);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("extraFilters") ||
changedProperties.has("stateObj")
) {
this.toggleAttribute("empty", this._filteredAttributes.length === 0);
}
}
protected render() {
if (!this.stateObj) {
return nothing;
}
const attributes = this._filteredAttributes;
if (attributes.length === 0) {
return nothing;
}
return html`
<ha-expansion-panel
.header=${this.hass.localize(
"ui.components.attributes.expansion_header"
)}
outlined
@expanded-will-change=${this._expandedChanged}
>
<div class="attribute-container">
${
this._expanded
? html`
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this.stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
`
: ""
}
</div>
</ha-expansion-panel>
${
this.stateObj.attributes.attribution
? html`
<div class="attribution">
${this.stateObj.attributes.attribution}
</div>
`
: ""
}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.attribute-container {
margin-bottom: 8px;
direction: ltr;
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: 16px;
}
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 16px 0;
}
`,
];
}
private _computeDisplayAttributes(filtersArray: string[]): string[] {
if (!this.stateObj) {
return [];
}
return Object.keys(this.stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
}
private _expandedChanged(ev) {
this._expanded = ev.detail.expanded;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-attributes": HaAttributes;
}
}
+2 -1
View File
@@ -29,7 +29,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
*
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {("accent"|"filled"|"outlined"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
* @attr {boolean} disabled - Disables the button and prevents user interaction.
*/
@@ -199,6 +199,7 @@ export class HaButton extends Button {
:host([appearance~="outlined"]) .button.disabled {
background-color: transparent;
color: var(--ha-color-on-disabled-quiet);
border-color: var(--ha-color-on-disabled-quiet);
}
@media (hover: hover) {
+3 -3
View File
@@ -101,7 +101,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionClick(ev: MouseEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this.value = value;
fireEvent(this, "value-changed", { value });
}
@@ -109,7 +109,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionMouseDown(ev: MouseEvent) {
if (this.disabled) return;
ev.preventDefault();
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
@@ -121,7 +121,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionFocus(ev: FocusEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
+43 -49
View File
@@ -2,7 +2,12 @@ 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 { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,11 +39,11 @@ export class HaFilterBlueprints extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -56,7 +61,6 @@ export class HaFilterBlueprints extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -71,29 +75,38 @@ export class HaFilterBlueprints extends LitElement {
: nothing
}
</div>
${
this._blueprints && this._shouldRender
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</div>
`
: nothing
}
`;
}
@@ -104,19 +117,6 @@ export class HaFilterBlueprints extends LitElement {
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -191,17 +191,11 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+23 -53
View File
@@ -8,9 +8,14 @@ import {
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -47,9 +52,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
protected hassSubscribeRequiredHostProps = ["scope"];
@@ -70,7 +75,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -85,9 +89,11 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._categorySelected}
class="ha-scrollbar"
@@ -158,34 +164,17 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
@@ -244,10 +233,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -287,19 +272,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
: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;
@@ -325,6 +299,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
color: var(--text-primary-color);
}
ha-list {
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
@@ -339,12 +315,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
-287
View File
@@ -1,287 +0,0 @@
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;
}
}
+72 -97
View File
@@ -3,7 +3,12 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -59,13 +64,15 @@ export class HaFilterDevices extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -77,26 +84,11 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
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">
@@ -112,31 +104,33 @@ export class HaFilterDevices extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
</div>`
: nothing
}
`;
}
@@ -177,10 +171,6 @@ export class HaFilterDevices extends LitElement {
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -271,58 +261,43 @@ export class HaFilterDevices extends LitElement {
this._listElement?.clearSelection();
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 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: 0;
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;
}
`;
static styles = [
filterPanelStyles,
css`
ha-list-selectable-virtualized {
flex: 1;
min-height: 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: 0;
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 {
+54 -70
View File
@@ -1,11 +1,16 @@
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 type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -41,18 +46,17 @@ export class HaFilterDomains extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
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">
@@ -67,46 +71,48 @@ export class HaFilterDomains extends LitElement {
: 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._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<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._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
`;
}
@@ -139,22 +145,6 @@ export class HaFilterDomains extends LitElement {
}
);
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) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -199,17 +189,11 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+21 -37
View File
@@ -2,8 +2,13 @@ import { consume, type ContextType } from "@lit/context";
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 { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -52,11 +57,11 @@ export class HaFilterEntities extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -78,7 +83,6 @@ export class HaFilterEntities extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -93,9 +97,11 @@ export class HaFilterEntities extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@@ -118,25 +124,13 @@ export class HaFilterEntities extends LitElement {
>
</lit-virtualizer>
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
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 _keyFunction = (entity) => entity?.entity_id;
private _renderItem = (entity) =>
@@ -173,10 +167,6 @@ export class HaFilterEntities extends LitElement {
listItem.selected = this.value?.includes(value);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -255,17 +245,11 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+519
View File
@@ -0,0 +1,519 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiChevronDown,
mdiChevronUp,
mdiFilterVariantRemove,
mdiShape,
} 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 { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeRTL } from "../common/util/compute_rtl";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import {
entityTypeKey,
parseEntityType,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
deviceClass?: string;
name: string;
deviceClasses?: string[];
expanded?: boolean;
last?: boolean;
}
@customElement("ha-filter-entity-types")
export class HaFilterEntityTypes 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 _filter?: string;
@state() private _expandedDomains = new Set<string>();
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _badgeTypes?: Map<string, string[]>;
protected render() {
const count = this.value?.length
? this._count(this.value, this._badgeTypes!)
: 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-entity-types.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const rows = this._rows(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this._expandedDomains
);
const rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
return html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.components.filter-entity-types.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
rows,
(row) => row.key,
(row) => this._renderRow(row, rtl)
)}
</ha-list-selectable>
</div>`;
}
private _renderRow(row: TypeRow, rtl: boolean) {
const selected = this._isSelected(row);
const expandable = !!row.deviceClasses?.length;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
class=${classMap({ child: !!row.deviceClass, rtl })}
.value=${row.key}
.selected=${selected}
.indeterminate=${!selected && this._isPartiallySelected(row)}
>
${
row.deviceClass
? html`<ha-tree-indicator
slot="start"
.end=${!!row.last}
></ha-tree-indicator>`
: nothing
}
${
row.deviceClass === NO_DEVICE_CLASS
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!row.deviceClass}
></ha-domain-icon>`
}
<span slot="headline">${row.name}</span>
${
expandable
? html`<ha-icon-button
slot="end"
data-domain=${row.domain}
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
.label=${this._localize(
row.expanded
? "ui.components.filter-entity-types.collapse"
: "ui.components.filter-entity-types.expand"
)}
@click=${this._toggleDomain}
@keydown=${this._handleChevronKeydown}
></ha-icon-button>`
: nothing
}
</ha-list-item-option>
`;
}
private _types = memoizeOne(usedEntityTypes);
// A selected domain counts for the classes it stands for, so that collapsing
// the last one does not drop the count to one.
private _count = memoizeOne(
(value: string[], types: Map<string, string[]>): number =>
value.reduce((count, key) => {
const { domain, deviceClass } = parseEntityType(key);
return (
count +
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
);
}, 0)
);
private _rows = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
expandedDomains: Set<string>
): TypeRow[] => {
const types = this._types(states);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
);
const domainMatches = this._matches(filter, domain, name);
if (!domainMatches && !matchingClasses.length) {
continue;
}
// Only a search that matched nothing but device classes unfolds them.
const revealed = !!filter && !domainMatches;
const expanded = revealed || expandedDomains.has(domain);
rows.push({
key: domain,
domain,
name,
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
expanded,
});
if (!deviceClasses.length || !expanded) {
continue;
}
const children = revealed ? matchingClasses : deviceClasses;
children.forEach((entry, index) => {
rows.push({
key: entityTypeKey(domain, entry.deviceClass),
domain,
deviceClass: entry.deviceClass,
name: entry.name,
last: index === children.length - 1,
});
});
}
return rows;
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
name: string
): boolean {
return (
!filter ||
slug.toLowerCase().includes(filter) ||
name.toLowerCase().includes(filter)
);
}
private _isSelected(row: TypeRow): boolean {
const value = this.value;
if (!value?.length) {
return false;
}
return value.includes(row.domain) || value.includes(row.key);
}
private _isPartiallySelected(row: TypeRow): boolean {
if (row.deviceClass || !this.value?.length) {
return false;
}
return this.value.some(
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
);
}
public willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// While closed, the badge reuses the classes it last saw rather than
// rescanning every entity on each state change.
if (this._panel.showContent || !this._badgeTypes) {
this._badgeTypes = this._types(this._states);
}
if (changed.has("expanded") && this.expanded) {
this._expandedDomains = new Set(
(this.value ?? [])
.map((key) => parseEntityType(key))
.filter((type) => type.deviceClass)
.map((type) => type.domain)
);
}
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
// The list activates the focused row on Enter and Space, which would select
// the domain instead of expanding it.
private _handleChevronKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
}
private _toggleDomain(ev: Event) {
ev.stopPropagation();
const { domain } = (ev.currentTarget as HTMLElement).dataset;
if (!domain) {
return;
}
const expandedDomains = new Set(this._expandedDomains);
if (!expandedDomains.delete(domain)) {
expandedDomains.add(domain);
}
this._expandedDomains = expandedDomains;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The list indexes its items by registration order, which a search reorders,
// so read the key off the clicked option instead.
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = new Set(this.value ?? []);
const siblings = (this._types(this._states).get(domain) ?? []).map(
(entry) => entityTypeKey(domain, entry)
);
// Drops the classes the domain no longer exposes too, so that a stale key
// can never sit next to the domain that covers it.
const selectDomain = () => {
value.forEach((selected) => {
if (parseEntityType(selected).domain === domain) {
value.delete(selected);
}
});
value.add(domain);
};
if (!deviceClass) {
if (!value.delete(domain)) {
selectDomain();
}
} else if (value.delete(domain)) {
siblings.forEach((sibling) => {
if (sibling !== key) {
value.add(sibling);
}
});
} else if (!value.delete(key)) {
value.add(key);
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
selectDomain();
}
}
this.value = [...value];
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 [
filterPanelStyles,
css`
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 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;
}
/* Keeps a row that carries the chevron as tall as one that does not. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
.child::part(base) {
padding-inline-start: 48px;
}
ha-tree-indicator {
width: 56px;
position: absolute;
top: 0px;
left: 0px;
}
.rtl ha-tree-indicator {
right: 0px;
left: initial;
transform: scaleX(-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entity-types": HaFilterEntityTypes;
}
}
+21 -32
View File
@@ -4,8 +4,13 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
@@ -64,10 +69,12 @@ export class HaFilterFloorAreas extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -86,7 +93,6 @@ export class HaFilterFloorAreas extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -107,9 +113,11 @@ export class HaFilterFloorAreas extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list-selectable
class="ha-scrollbar"
multi
@@ -154,10 +162,10 @@ export class HaFilterFloorAreas extends LitElement {
(area) => this._renderArea(area)
)}
</ha-list-selectable>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
@@ -243,19 +251,6 @@ export class HaFilterFloorAreas extends LitElement {
};
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -347,17 +342,11 @@ export class HaFilterFloorAreas extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list-selectable {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+60 -72
View File
@@ -1,11 +1,16 @@
import type { SelectedDetail } from "@material/mwc-list";
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -44,11 +49,11 @@ export class HaFilterIntegrations extends LitElement {
Object.values(manifests)
);
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const manifests = this._manifests
@@ -59,7 +64,6 @@ export class HaFilterIntegrations extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -74,67 +78,57 @@ export class HaFilterIntegrations extends LitElement {
: nothing
}
</div>
${
manifests && this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
${
manifests
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list>`
: nothing
}
</div>`
: nothing
}
`;
}
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) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -213,17 +207,11 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+62 -94
View File
@@ -1,11 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -44,11 +49,11 @@ export class HaFilterLabels extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
@@ -75,7 +80,6 @@ export class HaFilterLabels extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -90,89 +94,66 @@ export class HaFilterLabels extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._manageLabels}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</div>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
}, 300);
}
}
private _manageLabels() {
navigate("/config/labels");
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -227,18 +208,11 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
@@ -267,12 +241,6 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+22 -45
View File
@@ -1,8 +1,13 @@
import type { List, SelectedDetail } from "@material/mwc-list";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
@@ -27,9 +32,9 @@ export class HaFilterStates extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list!: List;
private _panel = new FilterPanelController(this, this._content);
protected render() {
if (!this.states) {
@@ -40,7 +45,6 @@ export class HaFilterStates extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -55,9 +59,11 @@ export class HaFilterStates extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._statesSelected}
multi
@@ -82,36 +88,13 @@ export class HaFilterStates extends LitElement {
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
this._shouldRender = true;
}
}
protected updated(changed: PropertyValues<this>) {
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
setTimeout(async () => {
if (!this.expanded) return;
const list = this._list;
if (!list) {
return;
}
list.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -152,17 +135,11 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+20 -34
View File
@@ -2,8 +2,13 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,16 +39,15 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
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">
@@ -58,9 +62,11 @@ export class HaFilterVoiceAssistants extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-list
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
@@ -83,10 +89,10 @@ export class HaFilterVoiceAssistants extends LitElement {
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
</ha-list>
</div>`
: nothing
}
`;
}
@@ -95,19 +101,6 @@ export class HaFilterVoiceAssistants extends LitElement {
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -148,18 +141,11 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+49 -50
View File
@@ -2,15 +2,15 @@ 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 memoizeOne from "memoize-one";
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 { entityTypeFilterFunc } from "../data/entity/entity_type";
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-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
@@ -19,8 +19,8 @@ import "./ha-target-picker";
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
types?: string[];
integrations?: string[];
}
@@ -44,38 +44,30 @@ 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.
* Matches an entity against the selected filters: it is kept when it matches
* every filter that has a selection. Undefined when nothing is selected.
*/
export const applySourceFilters = (
entityIds: string[],
export const sourceFilterFunc = (
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
): ((entityId: string) => boolean) | undefined => {
const matchesType = filters.types?.length
? entityTypeFilterFunc(filters.types, states)
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
if (!matchesType && !integrations) {
return undefined;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return (entityId: string) => {
if (matchesType && !matchesType(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;
@@ -84,13 +76,24 @@ export const applySourceFilters = (
}
}
return true;
});
};
};
/** Narrows entity IDs down by the selected filters. */
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const matches = sourceFilterFunc(filters, states, entities, entitySources);
return matches ? entityIds.filter(matches) : entityIds;
};
/**
* 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`.
* entity type 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.
@@ -106,6 +109,8 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public entitySources?: EntitySources;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@@ -129,6 +134,12 @@ export class HaSourcesPicker extends LitElement {
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
@@ -136,18 +147,12 @@ export class HaSourcesPicker extends LitElement {
<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-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@@ -158,6 +163,8 @@ export class HaSourcesPicker extends LitElement {
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
@@ -168,12 +175,8 @@ export class HaSourcesPicker extends LitElement {
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 _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
@@ -191,12 +194,8 @@ export class HaSourcesPicker extends LitElement {
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
+11
View File
@@ -108,6 +108,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* counts, unlike `entityFilter`, which says what can be picked at all.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _selectedSection?: TargetTypeFloorless;
@@ -286,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -306,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -329,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -348,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -24,6 +24,7 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
*
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
* @attr {string} value - Value identifying the option.
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
@@ -32,6 +33,8 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public indeterminate = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
@@ -80,6 +83,7 @@ export class HaListItemOption extends HaListItemBase {
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.indeterminate=${this.indeterminate}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
@@ -10,6 +10,8 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
@property({ type: Boolean, reflect: true }) public controlled = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
@@ -67,15 +69,19 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
el.toggleAttribute("selected");
if (!this.controlled) {
el.toggleAttribute("selected");
}
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
if (!this.controlled) {
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
}
@@ -20,6 +20,9 @@ import { HaListVirtualized } from "./ha-list-virtualized";
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
@@ -11,6 +11,9 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
@@ -470,15 +470,19 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const canPickCurrent =
currentItem?.can_play ||
(currentItem && this.accept?.includes("directory"));
return html`
${
currentItem.can_play || showSearch
canPickCurrent || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !currentItem.can_play,
"search-only": !canPickCurrent,
})}"
@transitionend=${this._setHeaderHeight}
>
@@ -491,7 +495,7 @@ export class HaMediaPlayerBrowse extends LitElement {
: nothing
}
${
currentItem.can_play
canPickCurrent
? html`<div class="header-content">
${
currentItem.thumbnail
@@ -503,7 +507,7 @@ export class HaMediaPlayerBrowse extends LitElement {
></ha-media-browser-thumbnail>
${
this.narrow &&
currentItem?.can_play &&
canPickCurrent &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
@@ -546,7 +550,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
</div>
${
currentItem.can_play &&
canPickCurrent &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
);
};
private _combinedFilter = memoizeOne(
(
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
activeFilter: (entityId: string) => boolean
): HaEntityPickerEntityFilterFunc =>
(stateObj) =>
(!entityFilter || entityFilter(stateObj)) &&
activeFilter(stateObj.entity_id)
);
private _selectorTarget() {
return this._params?.selector?.target || null;
}
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
const { activeFilter } = this._params;
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
if (activeFilter) {
entityFilter = this._combinedFilter(entityFilter, activeFilter);
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
activeFilter?: (entityId: string) => boolean;
includeDomains?: string[];
includeDeviceClasses?: string[];
primaryEntitiesOnly?: boolean;
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* count, and the target details, but not what the target resolves to.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</span>`
: html`<ha-button
appearance="filled"
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
size="xs"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</ha-button>`
}
</div>
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
`;
}
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
const total = entries.referenced_entities.length;
return {
total,
count: this.activeFilter
? entries.referenced_entities.filter(this.activeFilter).length
: total,
};
}
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
const { count, total } = this._entityCounts(entries);
return this.activeFilter
? this.hass.localize(
"ui.components.target-picker.entities_count_filtered",
{ count, total }
)
: this.hass.localize("ui.components.target-picker.entities_count", {
count,
});
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
@@ -816,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
activeFilter: this.activeFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
+189 -56
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -7,8 +8,16 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { ConditionDescriptions } from "../../data/condition";
import {
conditionDescriptionsContext,
fullEntitiesContext,
labelsContext,
manifestsContext,
triggerDescriptionsContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { DomainManifestLookup } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script_i18n";
@@ -17,7 +26,12 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import type { TargetSelector } from "../../data/selector";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import type { TriggerDescriptions } from "../../data/trigger";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
@@ -67,6 +81,18 @@ export class HaTracePathDetails extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions?: TriggerDescriptions;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
@@ -191,51 +217,8 @@ export class HaTracePathDetails extends LitElement {
)}`;
}
const selectedType = this.selected.type;
return html`
${
curPath === this.selected.path
? currentDetail.alias
? html`<h2>${currentDetail.alias}</h2>`
: selectedType === "trigger"
? html`<h2>
${describeTrigger(
migrateAutomationTrigger({
...currentDetail,
}) as Trigger,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "condition"
? html`<h2>
${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}
</h2>`
: selectedType === "action"
? html`<h2>
${describeAction(
this.hass,
this._entityReg,
currentDetail
)}
</h2>`
: selectedType === "chooseOption"
? html`<h2>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)}
</h2>`
: nothing
: html`<h2>
${curPath.substring(this.selected.path.length + 1)}
</h2>`
}
${this._renderStepHeading(curPath, currentDetail, pathParts)}
${
data.length === 1
? nothing
@@ -246,17 +229,7 @@ export class HaTracePathDetails extends LitElement {
)}
</h3>`
}
${
curPath
.substring(this.selected.path.length + 1)
.includes("condition")
? html`[${describeCondition(
currentDetail,
this.hass,
this._entityReg
)}]<br />`
: nothing
}
${this._renderNestedCondition(curPath, currentDetail)}
${this.hass!.localize(
"ui.panel.config.automation.trace.path.executed",
{
@@ -324,6 +297,130 @@ export class HaTracePathDetails extends LitElement {
return parts;
}
private _renderStepHeading(
curPath: string,
currentDetail: any,
pathParts: string[]
) {
if (curPath !== this.selected.path) {
return html`<div class="heading">
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
</div>`;
}
const selectedType = this.selected.type;
const description = currentDetail.alias
? currentDetail.alias
: selectedType === "trigger"
? describeTrigger(
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
this.hass,
this._entityReg
)
: selectedType === "condition"
? describeCondition(currentDetail, this.hass, this._entityReg)
: selectedType === "action"
? describeAction(
this.hass,
this._entityReg,
currentDetail,
undefined,
false,
this._manifests
)
: selectedType === "chooseOption"
? this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: pathParts[pathParts.length - 1] }
)
: undefined;
if (description === undefined) {
return nothing;
}
return html`<div class="heading">
<h2>${description}</h2>
${this._renderTargets(currentDetail, selectedType)}
</div>`;
}
private _renderNestedCondition(curPath: string, currentDetail: any) {
if (
!curPath.substring(this.selected.path.length + 1).includes("condition")
) {
return nothing;
}
return html`<div class="nested-condition">
${describeCondition(currentDetail, this.hass, this._entityReg)}
${this._renderTargets(currentDetail, "condition", "s")}
</div>`;
}
private _renderTargets(
config: any,
type: NodeInfo["type"],
size: "s" | "m" = "m"
) {
const target = this._getTarget(config, type);
if (!target) {
return nothing;
}
const targetSpec = this._getTargetSelector(config, type);
return html`<div class="targets">
<ha-automation-row-targets
.target=${target}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.size=${size}
interactive
></ha-automation-row-targets>
</div>`;
}
private _getTargetSelector(
config: any,
type: NodeInfo["type"]
): TargetSelector["target"] | undefined {
if (type === "trigger") {
return this._triggerDescriptions?.[config.trigger]?.target;
}
if (type === "condition") {
return this._conditionDescriptions?.[config.condition]?.target;
}
if (type === "action" && typeof config.action === "string") {
const [domain, service] = config.action.split(".", 2);
return this.hass.services?.[domain]?.[service]?.target;
}
return undefined;
}
private _getTarget(
config: any,
type: NodeInfo["type"]
): HassServiceTarget | undefined {
if (config.target) {
return config.target;
}
if (type === "trigger" || type === "condition") {
const element = type === "trigger" ? config.trigger : config.condition;
if (element === "state" || element === "numeric_state") {
return getEntityTarget(config.entity_id);
}
if (element === "device") {
return getDeviceTarget(config.device_id);
}
return undefined;
}
if (type === "action") {
return config.entity_id
? getEntityTarget(config.entity_id)
: getDeviceTarget(config.device_id);
}
return undefined;
}
private _renderSelectedConfig() {
if (!this.selected?.path) {
return nothing;
@@ -463,6 +560,42 @@ export class HaTracePathDetails extends LitElement {
min-height: 250px;
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.heading h2 {
margin: 0;
}
.heading .targets {
margin-top: 0;
}
.targets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-top: var(--ha-space-2);
}
.nested-condition {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.nested-condition .targets {
margin-top: 0;
}
pre {
margin: 0;
}
+35 -223
View File
@@ -149,9 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
@@ -210,8 +207,7 @@ const tryDescribeTrigger = (
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -235,8 +231,7 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -267,12 +262,7 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
if (trigger.trigger === "numeric_state") {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
@@ -292,82 +282,23 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
if (trigger.above !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: formatNumericLimitValue(hass, trigger.above),
duration: duration,
}
);
}
if (trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.below`,
{
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
);
}
// State Trigger
if (trigger.trigger === "state") {
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
@@ -463,39 +394,12 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
hasEntity: entities.length !== 0 ? "true" : "false",
entity: formatListWithOrs(hass.locale, entities),
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
@@ -1037,8 +941,7 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry,
options?.hideEntities
entityRegistry
);
if (description) {
@@ -1064,8 +967,7 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
hideEntities = false
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1122,12 +1024,6 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
@@ -1184,51 +1080,14 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
: formatListWithAnds(hass.locale, entities),
numberOfStates: states.length,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
@@ -1237,10 +1096,7 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
if (condition.condition === "numeric_state") {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
@@ -1257,64 +1113,20 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
if (condition.above !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
above: formatNumericLimitValue(hass, condition.above),
}
);
}
if (condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
{
attribute,
entity,
numberOfEntities: entity_ids.length,
below: formatNumericLimitValue(hass, condition.below),
}
);
}
}
// Time condition
+9 -3
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
import type { RepairsIssue } from "./repairs";
export type FlowType =
"config_flow" | "config_subentries_flow" | "options_flow" | "repair_flow";
@@ -56,26 +57,31 @@ export interface DataEntryFlowStepExternal {
translation_domain?: string;
}
export interface DataEntryFlowStepCreateEntry {
export interface DataEntryFlowStepCreateEntry<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
type: "create_entry";
version: number;
flow_id: string;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
handler: string;
title: string;
result?: ConfigEntry;
result?: TResult;
description: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
}
export interface DataEntryFlowStepAbort {
export interface DataEntryFlowStepAbort<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
type: "abort";
flow_id: string;
handler: string;
reason: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
result?: TResult;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
}
+180
View File
@@ -0,0 +1,180 @@
// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with `script/gen_device_classes`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
sensor: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"date",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"enum",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"timestamp",
"uptime",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
+171 -54
View File
@@ -21,7 +21,6 @@ import {
} from "../common/datetime/calc_date";
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";
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
};
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
if (collection._refreshTimeout) {
clearTimeout(collection._refreshTimeout);
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
}
if (collection._active && (!collection.end || collection.end > new Date())) {
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
export const getNextEnergyPeriodStart = (
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
export const getEnergyFirstStatisticAt = (
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
// The statistics Energy view shows yesterday until 01:00 so the graph is not
// empty. The real-time "Now" view never does this — it has live data.
export const shouldFallbackEnergyPeriodToYesterday = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): boolean =>
!midnightRollover &&
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
// Live day used while a rollover timer is scheduled (today, or the hour-0
// yesterday fallback). Custom dates do not use this. If the user already
// picked today during hour 0, keep today rather than snapping back.
export const getEnergyLiveDayPeriod = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
currentStart: Date
): { start: Date; end: Date } => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
currentStart.getTime() !== todayStart.getTime()
) {
const yesterday = calcDate(now, addDays, locale, config, -1);
return {
start: calcDate(yesterday, startOfDay, locale, config),
end: calcDate(yesterday, endOfDay, locale, config),
};
}
return {
start: todayStart,
end: calcDate(now, endOfDay, locale, config),
};
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
// Pass `periodStart` when the collection is on a specific day: hour-0
// yesterday (and any older stale live day) must wake at today 01:00, not
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
periodStart?: Date
): Date => {
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
const todayStart = calcDate(now, startOfDay, locale, config);
if (
periodStart &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
periodStart.getTime() !== todayStart.getTime()
) {
return getEnergyFirstStatisticAt(now, locale, config);
}
// Next midnight in the configured zone, not browser-local addDays, so a
// DST transition cannot skip a server-tz day.
const nextMidnight = addMilliseconds(
calcDate(now, endOfDay, locale, config),
1
);
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
};
export const getEnergyDataCollection = (
@@ -929,12 +1002,80 @@ export const getEnergyDataCollection = (
}
) as EnergyCollection;
collection._active = 0;
collection.prefs = options.prefs;
// True while the collection is tracking the rolling "today" (or hour-0
// yesterday) day. Cleared when the user picks a custom range.
let followLiveDay = false;
const applyLiveDayPeriod = (now: Date): boolean => {
const live = getEnergyLiveDayPeriod(
midnightRollover,
now,
hass.locale,
hass.config,
collection.start
);
const changed =
collection.start.getTime() !== live.start.getTime() ||
collection.end?.getTime() !== live.end.getTime();
collection.start = live.start;
collection.end = live.end;
return changed;
};
const clearUpdatePeriodTimeout = () => {
if (collection._updatePeriodTimeout !== undefined) {
window.clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
};
const scheduleUpdatePeriod = () => {
clearUpdatePeriodTimeout();
const scheduledAt = new Date();
collection._updatePeriodTimeout = window.setTimeout(
() => {
if (applyLiveDayPeriod(new Date())) {
collection.refresh();
}
scheduleUpdatePeriod();
},
Math.max(
0,
getNextEnergyPeriodStart(
midnightRollover,
scheduledAt,
hass.locale,
hass.config,
collection.start
).getTime() - scheduledAt.getTime()
)
);
};
const origSubscribe = collection.subscribe;
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
// Catch up before origSubscribe so the first fetch uses the live day.
// Refresh only when state already exists: cold subscribe fetches via
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
const needsRefresh =
followLiveDay &&
applyLiveDayPeriod(new Date()) &&
collection.state !== undefined;
if (followLiveDay) {
scheduleUpdatePeriod();
}
const unsub = origSubscribe(subscriber);
collection._active++;
if (needsRefresh) {
collection.refresh();
}
if (collection._refreshTimeout === undefined) {
scheduleHourlyRefresh(collection);
}
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
return () => {
collection._active--;
if (collection._active < 1) {
clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
clearUpdatePeriodTimeout();
}
unsub();
};
};
collection._active = 0;
collection.prefs = options.prefs;
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const now = new Date();
const preferredPeriod =
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" && hour === "0" && !midnightRollover
preferredPeriod === "today" &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
hass.locale,
hass.config
)
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
const scheduleUpdatePeriod = () => {
collection._updatePeriodTimeout = window.setTimeout(
() => {
collection.start = calcDate(
new Date(),
startOfDay,
hass.locale,
hass.config
);
collection.end = calcDate(
new Date(),
endOfDay,
hass.locale,
hass.config
);
collection.refresh();
scheduleUpdatePeriod();
},
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
followLiveDay = preferredPeriod === "today";
collection.isActive = () => !!collection._active;
collection.clearPrefs = () => {
collection.prefs = undefined;
};
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
if (collection._updatePeriodTimeout) {
clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
clearUpdatePeriodTimeout();
collection.start = newStart;
collection.end = newEnd;
if (
const periodNow = new Date();
followLiveDay =
collection.start.getTime() ===
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
collection.end?.getTime() ===
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
) {
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
if (followLiveDay) {
scheduleUpdatePeriod();
}
};
+75
View File
@@ -0,0 +1,75 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
deviceClass;
-45
View File
@@ -1,38 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
"attribution",
"custom_ui_more_info",
"custom_ui_state_card",
"device_class",
"editable",
"emulated_hue_name",
"emulated_hue",
"entity_picture",
"event_types",
"friendly_name",
"haaska_hidden",
"haaska_name",
"icon",
"initial_state",
"last_reset",
"restored",
"state_class",
"supported_features",
"unit_of_measurement",
"available_tones",
];
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],
},
};
export const TEMPERATURE_ATTRIBUTES = new Set([
"temperature",
@@ -210,15 +177,3 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"swing_modes",
"token",
];
export const computeShownAttributes = (stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
stateObj.attributes?.device_class
] || []
);
return Object.keys(stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
};
+91
View File
@@ -0,0 +1,91 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
export interface EntityType {
domain: string;
deviceClass?: string;
}
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
export const parseEntityType = (key: string): EntityType => {
const index = key.indexOf(SEPARATOR);
return index === -1
? { domain: key }
: {
domain: key.slice(0, index),
deviceClass: key.slice(index + SEPARATOR.length),
};
};
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
for (const stateObj of Object.values(states)) {
const domain = computeDomain(stateObj.entity_id);
let classes = byDomain.get(domain);
if (!classes) {
classes = new Set();
byDomain.set(domain, classes);
}
if (domain in DOMAIN_DEVICE_CLASSES) {
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
}
}
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
])
);
};
// Relies on a domain and its device classes never being selected at once.
export const entityTypeFilterFunc = (
types: string[],
states: HomeAssistant["states"]
): ((entityId: string) => boolean) => {
const domains = new Set<string>();
const deviceClasses = new Map<string, Set<string>>();
for (const key of types) {
const { domain, deviceClass } = parseEntityType(key);
if (deviceClass === undefined) {
domains.add(domain);
} else {
let classes = deviceClasses.get(domain);
if (!classes) {
classes = new Set();
deviceClasses.set(domain, classes);
}
classes.add(deviceClass);
}
}
return (entityId: string) => {
const domain = computeDomain(entityId);
if (domains.has(domain)) {
return true;
}
const classes = deviceClasses.get(domain);
if (!classes) {
return false;
}
const stateObj = states[entityId];
if (!stateObj) {
return false;
}
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
+13
View File
@@ -35,6 +35,18 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export type SecurityAlertSeverity = "alert" | "warning";
export interface SecurityAlertEntityConfig {
entity: string;
severity?: SecurityAlertSeverity;
}
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -51,6 +63,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
+10 -3
View File
@@ -126,11 +126,18 @@ export const listDatadisks = async (
timeout: null,
});
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
endpoint: `/host/disks/${disk}/usage`,
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_entity_constants";
import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
+431
View File
@@ -0,0 +1,431 @@
// This file is auto-generated from Home Assistant Core's `DEVICE_CLASS_UNITS`
// and `STATE_CLASS_UNITS`) and `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_sensor_entity_constants`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
aqi: [null],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
battery: ["%"],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
carbon_dioxide: ["ppm"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
humidity: ["%"],
illuminance: ["lx"],
irradiance: ["BTU/(h⋅ft²)", "W/m²"],
moisture: ["%"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
nitrous_oxide: ["μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
ph: [null],
pm1: ["μg/m³"],
pm10: ["μg/m³"],
pm25: ["μg/m³"],
pm4: ["μg/m³"],
power_factor: ["%", null],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
signal_strength: ["dB", "dBm"],
sound_pressure: ["dB", "dBA"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_direction: ["°"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<
string,
(string | null)[]
> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
power_factor: ["%", null],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_STATE_CLASSES: string[] = [
"measurement",
"measurement_angle",
"total",
"total_increasing",
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
measurement_angle: ["°"],
};
-64
View File
@@ -1,64 +0,0 @@
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_numeric_device_classes`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
+43 -4
View File
@@ -4,8 +4,11 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
import { createDurationData } from "../common/datetime/create_duration_data";
import durationToSeconds from "../common/datetime/duration_to_seconds";
import durationToSeconds, {
durationDataToSeconds,
} from "../common/datetime/duration_to_seconds";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
@@ -14,6 +17,8 @@ export type TimerEntity = HassEntityBase & {
duration: string;
remaining: string;
restore: boolean;
finishes_at?: string;
last_transition?: string;
};
};
@@ -64,6 +69,18 @@ export const deleteTimer = (hass: HomeAssistant, id: string) =>
timer_id: id,
});
// True when this state change is the timer completing: it ran out or
// timer.finish was called. Cancel also ends in "idle" but sets
// last_transition to "cancelled", so it does not match.
export const timerJustFinished = (
oldStateObj: HassEntity | undefined,
stateObj: HassEntity
): boolean =>
oldStateObj !== undefined &&
oldStateObj.state !== "idle" &&
stateObj.state === "idle" &&
stateObj.attributes.last_transition === "finished";
export const timerTimeRemaining = (
stateObj: HassEntity
): undefined | number => {
@@ -82,7 +99,7 @@ export const timerTimeRemaining = (
};
export const computeDisplayTimer = (
hass: HomeAssistant,
formatEntityState: FormatEntityStateFunc,
stateObj: HassEntity,
timeRemaining?: number
): string | null => {
@@ -91,18 +108,40 @@ export const computeDisplayTimer = (
}
if (stateObj.state === "idle" || timeRemaining === 0) {
return hass.formatEntityState(stateObj);
return formatEntityState(stateObj);
}
let display = secondsToDuration(timeRemaining || 0) || "0";
if (stateObj.state === "paused") {
display = `${display} (${hass.formatEntityState(stateObj)})`;
display = `${display} (${formatEntityState(stateObj)})`;
}
return display;
};
const leftPad = (num: number) => (num < 10 ? `0${num}` : `${num}`);
// Normalize duration data to whole-second hours/minutes/seconds fields, so
// out-of-range values ({seconds: 3600}) and fractional seconds cannot reach
// duration inputs or the serialized config. Timers only support whole seconds.
export const normalizeTimerDuration = (
data: HaDurationData
): HaDurationData => {
const totalSeconds = Math.floor(durationDataToSeconds(data));
return {
hours: Math.floor(totalSeconds / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
};
};
// Serialize duration data to the "H:MM:SS" format accepted by timer.start.
export const durationDataToTimerString = (data: HaDurationData): string => {
const { hours = 0, minutes = 0, seconds = 0 } = normalizeTimerDuration(data);
return `${hours}:${leftPad(minutes)}:${leftPad(seconds)}`;
};
// Prefill for the duration input: always the configured duration, independent
// of the live countdown. The field is meant to be edited, not to mirror the
// remaining time.
+30
View File
@@ -2,13 +2,37 @@ import type { HomeAssistant } from "../types";
export interface SerialPort {
device: string;
resolved_device: string | null;
serial_number: string | null;
manufacturer: string | null;
description: string | null;
interface_description?: string | null;
interface_num?: number | null;
vid?: string;
pid?: string;
bcd_device?: number | null;
matching_integrations: string[];
// Configured ports that are not currently connected are absent from a scan
present: boolean;
}
export interface SerialPortConsumer {
kind: "config_entry" | "app";
title: string;
active: boolean;
domain: string | null;
config_entry_id: string | null;
slug: string | null;
}
export interface SerialPortDiscoveryFlow {
flow_id: string;
domain: string;
}
export interface SerialPortUsage extends SerialPort {
consumers: SerialPortConsumer[];
discovery_flows: SerialPortDiscoveryFlow[];
}
export const scanUSBDevices = (hass: HomeAssistant) =>
@@ -16,3 +40,9 @@ export const scanUSBDevices = (hass: HomeAssistant) =>
export const listSerialPorts = (hass: HomeAssistant) =>
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
export const listSerialPortsWithUsage = (hass: HomeAssistant) =>
hass.callWS<SerialPortUsage[]>({
type: "usb/list_serial_ports",
include_usage: true,
});
@@ -12,12 +12,14 @@ import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { ConfigEntry } from "../../data/config_entries";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
subscribeDataEntryFlowProgress,
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { RepairsIssue } from "../../data/repairs";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -30,6 +32,7 @@ import type {
} from "./show-dialog-data-entry-flow";
import { showOptionsFlowDialog } from "./show-dialog-options-flow";
import { showSubConfigFlowDialog } from "./show-dialog-sub-config-flow";
import { showRepairsFlowDialog } from "../repairs-flow/show-dialog-repair-flow";
import "./step-flow-abort";
import "./step-flow-create-entry";
import "./step-flow-external";
@@ -218,7 +221,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.dialogClosedCallback({
flowFinished,
entryId:
"result" in this._step ? this._step.result?.entry_id : undefined,
"result" in this._step
? (this._step.result as ConfigEntry)?.entry_id
: undefined,
});
}
@@ -279,7 +284,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devicesLength = this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)?.entry_id,
this._params.carryOverDevices
).length;
return this.hass.localize(
@@ -485,7 +490,8 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)
?.entry_id,
this._params.carryOverDevices
)}
></step-flow-create-entry>
@@ -575,7 +581,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)?.entry_id,
this._params!.carryOverDevices
);
@@ -681,21 +687,23 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "options_flow") {
if (_step.type === "create_entry") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "repair_flow") {
showRepairsFlowDialog(this, _step.result as unknown as RepairsIssue, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else {
this.closeDialog();
showAlertDialog(this, {
@@ -15,6 +15,7 @@ import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
import { getConfigEntries } from "../../data/config_entries";
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
import type { ConfigEntry } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
@@ -40,7 +41,8 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
@property({ attribute: false })
public step!: DataEntryFlowStepCreateEntry<ConfigEntry>;
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@@ -79,7 +81,7 @@ class StepFlowCreateEntry extends LitElement {
if (
this.devices.length !== 1 ||
this.devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
this.step.result?.domain === "voip"
) {
return;
}
+277 -62
View File
@@ -1,29 +1,40 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-form/ha-form";
import type { HaDialog } from "../../components/ha-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
initialData: FormDialogData;
data: FormDialogData;
nestedField?: string;
scrollTop: number;
focusTarget?: Element;
error?: Record<string, string>;
}
@customElement("dialog-form")
export class DialogForm
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
extends DirtyStateProviderMixin<FormDialogData[]>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -32,6 +43,8 @@ export class DialogForm
@state() private _data: FormDialogData = {};
private _initialData: FormDialogData = {};
@state() private _open = false;
@state() private _closeState?: "canceled" | "submitted";
@@ -40,14 +53,19 @@ export class DialogForm
@state() private _error?: Record<string, string>;
@query("ha-form") private _form?: HaForm;
@query("ha-dialog") private _dialog?: HaDialog;
@query("ha-form:not([hidden])") private _form?: HaForm;
@queryAll("ha-form") private _forms!: NodeListOf<HaForm>;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._initialData = deepClone(this._data);
this._open = true;
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
}
public closeDialog(): boolean {
@@ -55,54 +73,217 @@ export class DialogForm
return true;
}
private _initialDirtyState(): FormDialogData[] {
return [
...this._stack.map((entry) => entry.initialData),
this._initialData,
];
}
private _currentDirtyState(): FormDialogData[] {
return [...this._stack.map((entry) => entry.data), this._data];
}
private _resetDirtyTracking(): void {
this._initDirtyTracking({ type: "deep" }, this._initialDirtyState());
this._updateDirtyState(this._currentDirtyState());
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (ev.detail.dialogTag !== "dialog-form") {
if (
ev.detail.dialogTag !== "dialog-form" ||
ev.currentTarget !== this._form
) {
return;
}
ev.stopPropagation();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
const nested = ev.detail.dialogParams as FormDialogParams;
if (!nested.submit || !nested.cancel) {
return;
}
ev.stopPropagation();
const focusTarget = deepActiveElement();
this._stack = [
...this._stack,
{
params: this._params!,
initialData: this._initialData,
data: this._data,
nestedField: origin?.name,
scrollTop: this._dialog?.bodyContainer.scrollTop ?? 0,
focusTarget: focusTarget ?? undefined,
error: this._error,
},
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._data = nested.data || {};
this._initialData = deepClone(this._data);
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
this._resetDirtyTracking();
void this._focusActiveForm(nested);
};
private _popStack(): string | undefined {
private _popStack(): StackEntry | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._initialData = prev.initialData;
this._data = prev.data;
this._error = prev.error;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
this._resetDirtyTracking();
return prev;
}
private async _afterFormRender(): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await nextRender();
}
private async _waitForSelectorElements(): Promise<void> {
const selectors = this._form?.shadowRoot?.querySelectorAll("ha-selector");
if (selectors?.length) {
await Promise.all(
Array.from(selectors, (element) =>
"updateComplete" in element
? (element as LitElement).updateComplete
: undefined
)
);
}
const pending = this._undefinedCustomElements(this._form);
if (!pending.length) {
return;
}
await Promise.all(pending.map((tag) => customElements.whenDefined(tag)));
await nextRender();
}
private _undefinedCustomElements(root?: ParentNode): string[] {
const tags = new Set<string>();
const visit = (node: ParentNode) => {
if (node instanceof Element && node.shadowRoot) {
visit(node.shadowRoot);
}
for (const child of node.children) {
if (
child.localName.includes("-") &&
!customElements.get(child.localName)
) {
tags.add(child.localName);
}
visit(child);
}
};
if (root) {
visit(root);
}
return [...tags];
}
private _focusFirstControl(root = this._form): void {
if (!root) {
return;
}
const visit = (node: ParentNode): HTMLElement | undefined => {
if (node instanceof Element && node.shadowRoot) {
const inShadow = visit(node.shadowRoot);
if (inShadow) {
return inShadow;
}
}
for (const child of node.children) {
if (
child instanceof HTMLElement &&
child.matches("input, textarea, select, button")
) {
return child;
}
const found = visit(child);
if (found) {
return found;
}
}
return undefined;
};
visit(root)?.focus();
}
private async _focusActiveForm(
expectedParams: FormDialogParams
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
return;
}
this._focusFirstControl();
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
} else {
this._focusFirstControl();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
for (let index = this._stack.length - 1; index >= 0; index--) {
this._stack[index].params.cancel?.();
}
}
if (this._closeState !== "submitted") {
this._discardDirtyStateChanges();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._initialData = {};
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -114,53 +295,69 @@ export class DialogForm
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
const nestedField = this._popStack();
const stackEntry = this._popStack();
submit?.(data);
if (!nestedField) {
if (!stackEntry) {
this._closeState = "submitted";
submit?.(data);
this._markDirtyStateClean();
this.closeDialog();
return;
}
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
submit!(data);
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const nestedField = this._popStack();
const stackEntry = this._popStack();
cancel?.();
if (!nestedField) {
if (!stackEntry) {
this._closeState = "canceled";
cancel?.();
this.closeDialog();
return;
}
cancel!();
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
}
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
const levelIndex = Array.from(this._forms).indexOf(
ev.currentTarget as HaForm
);
if (levelIndex === -1) {
return;
}
const data = ev.detail.value as FormDialogData;
if (levelIndex === this._stack.length) {
this._data = data;
this._error = undefined;
this._updateDirtyState(this._currentDirtyState());
return;
}
if (levelIndex < this._stack.length) {
this._stack = this._stack.map((entry, index) =>
index === levelIndex ? { ...entry, data, error: undefined } : entry
);
this._updateDirtyState(this._currentDirtyState());
}
}
protected render() {
@@ -168,35 +365,53 @@ export class DialogForm
return nothing;
}
const params = this._params;
const levels = [
...this._stack,
{
params,
initialData: this._initialData,
data: this._data,
error: this._error,
},
];
return html`
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
header-title=${params.title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
autofocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
${levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
})}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
${params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${this._params.submitText || this.hass.localize("ui.common.save")}
${params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
@@ -34,12 +34,4 @@ export const moreInfoControlStyle = css`
.buttons > * {
margin: var(--ha-space-2);
}
ha-attributes {
display: block;
width: 100%;
}
ha-more-info-control-select-container + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4);
}
`;
+1
View File
@@ -38,6 +38,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"siren",
"script",
"switch",
"timer",
"vacuum",
"valve",
"water_heater",
+152 -91
View File
@@ -2,6 +2,8 @@ import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { TimerRemainingTimeController } from "../../../common/controllers/timer-remaining-time-controller";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
@@ -9,10 +11,20 @@ import "../../../components/ha-button";
import type { HaButton } from "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import { apiContext } from "../../../data/context";
import { apiContext, formattersContext } from "../../../data/context";
import type { TimerEntity } from "../../../data/timer";
import { timerDurationData } from "../../../data/timer";
import type { HomeAssistantApi, ValueChangedEvent } from "../../../types";
import {
computeDisplayTimer,
timerDurationData,
timerJustFinished,
} from "../../../data/timer";
import type {
HomeAssistantApi,
HomeAssistantFormatters,
ValueChangedEvent,
} from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-timer")
class MoreInfoTimer extends LitElement {
@@ -26,8 +38,16 @@ class MoreInfoTimer extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state() private _duration?: HaDurationData;
@state() private _finished = false;
private _remainingTime = new TimerRemainingTimeController(this);
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Seed the field once from the configured duration and keep it static,
@@ -35,90 +55,117 @@ class MoreInfoTimer extends LitElement {
if (this._duration === undefined && this.stateObj) {
this._duration = timerDurationData(this.stateObj);
}
if (changedProps.has("stateObj")) {
this._remainingTime.setStateObj(this.stateObj);
if (
this.stateObj &&
timerJustFinished(changedProps.get("stateObj"), this.stateObj)
) {
this._finished = true;
}
}
}
private _finishedAnimationEnded(): void {
this._finished = false;
}
protected render() {
if (!this._localize || !this.stateObj) {
if (!this._localize || !this._formatters || !this.stateObj) {
return nothing;
}
const timerState = this.stateObj.state;
return html`
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
<ha-more-info-state-header
class=${classMap({ finished: this._finished })}
.stateObj=${this.stateObj}
.stateOverride=${
computeDisplayTimer(
this._formatters.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? undefined
}
@animationend=${this._finishedAnimationEnded}
></ha-more-info-state-header>
<div class="controls">
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${
timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this._localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing
}
${
timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing
}
${
timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"start"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing
}
${
timerState === "active" || timerState === "paused"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
size="s"
.action=${"finish"}
@click=${this._handleActionClick}
>
${this._localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing
}
</div>
</div>
`;
}
@@ -148,20 +195,34 @@ class MoreInfoTimer extends LitElement {
});
}
static styles = css`
ha-duration-input {
display: flex;
justify-content: center;
margin: var(--ha-space-4) 0 var(--ha-space-2);
}
.actions {
margin: var(--ha-space-2) 0;
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
`;
static styles = [
moreInfoControlStyle,
css`
ha-duration-input {
display: flex;
justify-content: center;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
ha-more-info-state-header.finished {
animation: timer-finished-pulse 0.5s ease-in-out 2;
}
@keyframes timer-finished-pulse {
50% {
color: var(--error-color);
}
}
@media (prefers-reduced-motion: reduce) {
ha-more-info-state-header.finished {
animation-duration: var(--ha-animation-duration-none);
}
}
`,
];
}
declare global {
+23 -22
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
@@ -15,7 +14,6 @@ import "../../components/ha-attribute-value";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
@@ -26,6 +24,7 @@ import type { FeatureEnum } from "../../common/entity/get_domain_features";
import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
interface DetailsViewParams {
entityId: string;
@@ -74,7 +73,10 @@ class HaMoreInfoDetails extends LitElement {
stateEntries,
attributes,
yamlData: stateYamlData,
} = this._getDetailData(this._stateObj);
} = this._getDetailData(
this._stateObj,
this.hass.formatEntityAttributeName
);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
@@ -212,10 +214,13 @@ class HaMoreInfoDetails extends LitElement {
private _getDetailData = memoizeOne(
(
stateObj: HassEntity
stateObj: HassEntity,
// cache key only: a new function is assigned when translation-based
// format functions reload, invalidating results formatted via this.hass
_formatEntityAttributeName: HomeAssistant["formatEntityAttributeName"]
): {
stateEntries: DetailEntry[];
attributes: string[];
attributes: { name: string; label: string }[];
yamlData: {
state: {
translated: string;
@@ -228,11 +233,14 @@ class HaMoreInfoDetails extends LitElement {
} => {
const translatedState = this.hass.formatEntityState(stateObj);
const detailsAttributes = computeShownAttributes(stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const attributes = Object.keys(stateObj.attributes)
.map((a) => ({
name: a,
label: this.hass.formatEntityAttributeName(stateObj, a),
}))
.sort((a, b) =>
stringCompare(a.label, b.label, this.hass.locale.language)
);
return {
stateEntries: [
@@ -253,7 +261,7 @@ class HaMoreInfoDetails extends LitElement {
value: this._formatTimestamp(stateObj.last_updated),
},
],
attributes: [...detailsAttributes, ...builtInAttributes],
attributes,
yamlData: {
state: {
translated: translatedState,
@@ -289,7 +297,7 @@ class HaMoreInfoDetails extends LitElement {
);
}
private _renderAttributes(attributes: string[]) {
private _renderAttributes(attributes: { name: string; label: string }[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
@@ -304,20 +312,13 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
<ha-list-item-value .label=${attribute.label}>
${
attribute === "supported_features" && featureEnum
attribute.name === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.attribute=${attribute.name}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
+8 -4
View File
@@ -1029,10 +1029,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
if (
this._currView === "settings" &&
this._entry &&
((changedProps.has("_currView") &&
changedProps.get("_currView") !== "settings") ||
(changedProps.has("_entry") && !changedProps.get("_entry")))
) {
this._initDirtyTracking({ type: "deep" });
}
if (changedProps.has("_currView")) {
@@ -1,8 +1,8 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
import type { HomeAssistant } from "../../types";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
@customElement("dialog-repairs-issue-subtitle")
class DialogRepairsIssueSubtitle extends LitElement {
@@ -1,18 +1,19 @@
import { html, nothing } from "lit";
import type { DataEntryFlowStep } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
import {
createRepairsFlow,
deleteRepairsFlow,
fetchRepairsFlow,
handleRepairsFlowStep,
} from "../../../data/repairs";
} from "../../data/repairs";
import type { DataEntryFlowDialogParams } from "../config-flow/show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../types";
} from "../config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../types";
import "./dialog-repairs-issue-subtitle";
const mergePlaceholders = (issue: RepairsIssue, step: DataEntryFlowStep) =>
@@ -36,14 +37,14 @@ export const loadRepairFlowDialog = loadDataEntryFlowDialog;
export const showRepairsFlowDialog = (
element: HTMLElement,
issue: RepairsIssue,
dialogClosedCallback?: (params: { flowFinished: boolean }) => void
dialogParams?: Omit<DataEntryFlowDialogParams, "flowConfig">
): void =>
showFlowDialog(
element,
{
startFlowHandler: issue.domain,
domain: issue.domain,
dialogClosedCallback,
...dialogParams,
},
{
flowType: "repair_flow",
+14 -14
View File
@@ -28,8 +28,8 @@ import type {
SortingDirection,
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-adaptive-dialog";
import "../components/ha-button";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
@@ -566,16 +566,17 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</hass-tabs-subpage>
${
this.showFilters && !showPane
? html`<ha-dialog
.open=${true}
? html`<ha-adaptive-dialog
open
flexcontent
width="full"
header-title=${localize("ui.components.subpage-data-table.filters")}
@closed=${this._closeFilters}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.path=${mdiClose}
@click=${this._closeFilters}
.label=${localize(
"ui.components.subpage-data-table.close_filter"
)}
@@ -596,13 +597,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="filter-pane"></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this._closeFilters}>
<ha-button slot="primaryAction" data-dialog="close">
${localize("ui.components.subpage-data-table.show_results", {
number: this.data.length,
})}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`
</ha-adaptive-dialog>`
: nothing
}
`;
@@ -934,20 +935,19 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
--md-assist-chip-trailing-space: 8px;
}
ha-dialog {
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));
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
}
.filter-dialog-content {
height: calc(
100vh -
70px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-dropdown ha-assist-chip {
@@ -42,6 +42,24 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
if (hasAreaSensor) {
return true;
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(generateEntityFilter(hass, filter))
);
};
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
@@ -62,9 +62,11 @@ import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import {
conditionDescriptionsContext,
fullEntitiesContext,
manifestsContext,
} from "../../../../data/context";
@@ -90,6 +92,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
@@ -205,6 +209,10 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions?: ConditionDescriptions;
@state() private _running = false;
@state() private _runResult?: {
@@ -292,13 +300,19 @@ export default class HaAutomationActionRow extends LitElement {
? this._extractTargets(this.action as ServiceAction)
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
: type === "condition"
? this._extractConditionTarget(this.action as Condition)
: undefined;
const serviceTargetSpec =
type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
type === "condition"
? this._conditionDescriptions?.[(this.action as Condition).condition]
?.target
: undefined;
: type === "service" && action
? this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
]?.target
: undefined;
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
@@ -777,6 +791,28 @@ export default class HaAutomationActionRow extends LitElement {
return {};
}
private _extractConditionTarget(
condition: Condition
): HassServiceTarget | undefined {
if (typeof condition !== "object") {
return undefined;
}
if ("target" in condition && condition.target) {
return condition.target;
}
if (
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
"entity_id" in condition
) {
return getEntityTarget(condition.entity_id);
}
if (condition.condition === "device" && "device_id" in condition) {
return getDeviceTarget(condition.device_id as string);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -226,9 +226,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
@@ -68,6 +68,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ type: Boolean })
public interactive = false;
@property({ reflect: true })
public size: "s" | "m" = "m";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -652,6 +655,23 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
:host([size="s"]) {
min-height: 24px;
}
:host([size="s"]) .target {
height: 24px;
}
/* A default 24px icon would fill the whole small chip. */
:host([size="s"]) .target ha-icon,
:host([size="s"]) .target ha-svg-icon,
:host([size="s"]) .target ha-domain-icon,
:host([size="s"]) .target ha-floor-icon {
--mdc-icon-size: 16px;
}
:host([size="s"]) .target ha-floor-icon {
height: 24px;
}
button.target {
cursor: pointer;
}
@@ -251,9 +251,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
describeTrigger(this.trigger, this.hass, this._entityReg)
)}
${
target !== undefined || targetRequired
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (_err: any) {
this._storageInfo = null;
}
+9
View File
@@ -3,6 +3,7 @@ import {
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
mdiCableData,
mdiCellphoneCog,
mdiCog,
mdiDatabase,
@@ -168,6 +169,14 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/serial",
iconPath: mdiCableData,
iconColor: "#607D8B",
component: "usb",
translationKey: "serial",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
@@ -1393,7 +1393,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry;
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
@@ -1459,7 +1459,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry;
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
+5
View File
@@ -212,6 +212,11 @@ class HaPanelConfig extends HassRouterPage {
load: () =>
import("./integrations/integration-panels/infrared/infrared-config-dashboard-router"),
},
serial: {
tag: "serial-config-dashboard",
load: () =>
import("./integrations/integration-panels/serial/serial-config-dashboard"),
},
dhcp: {
tag: "dhcp-config-panel",
load: () =>
@@ -0,0 +1,154 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@customElement("dialog-serial-port-info")
class DialogSerialPortInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: SerialPortInfoDialogParams;
@state() private _open = false;
public showDialog(params: SerialPortInfoDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _copyToClipboard(): Promise<void> {
await copyToClipboard(JSON.stringify(this._params!.port, null, 2));
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _fields(): [string, string][] {
const port = this._params!.port;
const localize = this.hass.localize;
const fields: [string, string | null | undefined][] = [
[localize("ui.panel.config.serial.fields.device"), port.device],
[
localize("ui.panel.config.serial.fields.resolved_device"),
port.resolved_device === port.device ? undefined : port.resolved_device,
],
[localize("ui.panel.config.serial.fields.description"), port.description],
[
localize("ui.panel.config.serial.fields.manufacturer"),
port.manufacturer,
],
[
localize("ui.panel.config.serial.fields.interface_description"),
port.interface_description,
],
[
localize("ui.panel.config.serial.fields.interface_number"),
port.interface_num?.toString(),
],
[
localize("ui.panel.config.serial.fields.serial_number"),
port.serial_number,
],
[localize("ui.panel.config.serial.fields.vid"), port.vid],
[localize("ui.panel.config.serial.fields.pid"), port.pid],
[
localize("ui.panel.config.serial.fields.bcd_device"),
port.bcd_device == null
? undefined
: port.bcd_device.toString(16).padStart(4, "0").toUpperCase(),
],
];
return fields.filter(([, value]) => value) as [string, string][];
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-serial-port-info": DialogSerialPortInfo;
}
}
@@ -0,0 +1,643 @@
import {
mdiAlertCircleOutline,
mdiCableData,
mdiCheck,
mdiConnection,
mdiInformationOutline,
mdiMemory,
mdiPowerPlugOff,
mdiPuzzle,
mdiRefresh,
mdiUsb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import { domainToName } from "../../../../../data/integration";
import type {
SerialPort,
SerialPortConsumer,
SerialPortDiscoveryFlow,
SerialPortUsage,
} from "../../../../../data/usb";
import { listSerialPortsWithUsage } from "../../../../../data/usb";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { mdiEsphomeLogo } from "../../../../../resources/esphome-logo-svg";
import { showSerialPortInfoDialog } from "./show-dialog-serial-port-info";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
const ESPHOME_HASS_SCHEME = "esphome-hass://";
type SerialPortType =
"serial_proxy" | "integration" | "usb" | "embedded" | "unnamed";
const TYPE_ICONS: Record<SerialPortType, string> = {
serial_proxy: mdiEsphomeLogo,
integration: mdiConnection,
usb: mdiUsb,
embedded: mdiMemory,
unnamed: mdiMemory,
};
const getPortType = (port: SerialPort): SerialPortType => {
if (port.device.startsWith(ESPHOME_HASS_SCHEME)) {
return "serial_proxy";
}
if (port.device.includes("://")) {
return "integration";
}
if (port.vid || port.pid) {
return "usb";
}
if (port.description || port.manufacturer) {
return "embedded";
}
return "unnamed";
};
interface PortListItem {
icon: string;
primary: string;
serialNumber?: string;
matchingIntegrations: string[];
consumers: SerialPortConsumer[];
discoveryFlows: SerialPortDiscoveryFlow[];
port: SerialPortUsage;
}
@customElement("serial-config-dashboard")
export class SerialConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _ports?: SerialPortUsage[];
@state() private _error?: string;
protected firstUpdated(): void {
this._fetchPorts();
}
private async _fetchPorts(): Promise<void> {
try {
this._ports = await listSerialPortsWithUsage(this.hass);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _portListItem(
port: SerialPortUsage,
localize: HomeAssistant["localize"]
): PortListItem {
const type = getPortType(port);
const productManufacturer =
port.description && port.manufacturer
? `${port.description}${port.manufacturer}`
: port.description || port.manufacturer;
let primary: string;
if (
port.interface_description &&
port.interface_description !== port.description
) {
primary = port.interface_description;
} else {
primary = productManufacturer || port.device;
}
return {
icon: port.present ? TYPE_ICONS[type] : mdiPowerPlugOff,
primary,
// The full port details are shown in the info dialog
serialNumber: port.serial_number
? localize("ui.panel.config.serial.serial_number", {
serial_number: port.serial_number,
})
: undefined,
matchingIntegrations: port.matching_integrations,
consumers: port.consumers,
discoveryFlows: port.discovery_flows,
port,
};
}
private _sortedPorts = memoizeOne(
(
ports: SerialPortUsage[],
localize: HomeAssistant["localize"],
language: string
): {
available: PortListItem[];
connected: PortListItem[];
disconnected: PortListItem[];
} => {
const available: PortListItem[] = [];
const connected: PortListItem[] = [];
const disconnected: PortListItem[] = [];
for (const port of ports) {
let bucket: PortListItem[];
if (!port.present) {
bucket = disconnected;
} else if (port.consumers.length) {
bucket = connected;
} else {
bucket = available;
}
bucket.push(this._portListItem(port, localize));
}
const byPrimary = (a: PortListItem, b: PortListItem) =>
caseInsensitiveStringCompare(a.primary, b.primary, language);
available.sort(byPrimary);
connected.sort(byPrimary);
disconnected.sort(byPrimary);
return { available, connected, disconnected };
}
);
private _consumerName(consumer: SerialPortConsumer): string {
const name =
consumer.title ||
(consumer.domain
? domainToName(this.hass.localize, consumer.domain)
: consumer.slug!);
return consumer.active
? name
: this.hass.localize("ui.panel.config.serial.consumer_not_running", {
name,
});
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
? `/config/integrations/integration/${consumer.domain}#config_entry=${consumer.config_entry_id}`
: `/config/app/${consumer.slug}/info`;
return html`
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: html`<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>`
}
<div slot="headline">${this._consumerName(consumer)}</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _renderDiscoveryFlow(flow: SerialPortDiscoveryFlow): TemplateResult {
return html`
<ha-md-list-item
type="button"
class="consumer"
.flowId=${flow.flow_id}
@click=${this._continueFlow}
>
<img
slot="start"
.src=${brandsUrl(
{
domain: flow.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${flow.domain}
/>
<div slot="headline">
${this.hass.localize("ui.panel.config.serial.discovered_by", {
integration: domainToName(this.hass.localize, flow.domain),
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _continueFlow(ev: Event): void {
showConfigFlowDialog(this, {
continueFlowId: (ev.currentTarget as any).flowId,
dialogClosedCallback: () => {
this._fetchPorts();
},
});
}
private _renderPortItem(item: PortListItem): TemplateResult {
const discoveredDomains = new Set(
item.discoveryFlows.map((flow) => flow.domain)
);
const remainingIntegrations = item.matchingIntegrations.filter(
(domain) => !discoveredDomains.has(domain)
);
const matchingIntegrations =
!item.consumers.length && remainingIntegrations.length
? remainingIntegrations
.map((domain) => domainToName(this.hass.localize, domain))
.join(", ")
: undefined;
return html`
<ha-md-list-item class="port">
<ha-svg-icon
slot="start"
class=${item.port.present ? "" : "disconnected"}
.path=${item.icon}
></ha-svg-icon>
<div slot="headline">${item.primary}</div>
${
item.serialNumber
? html`<div slot="supporting-text">${item.serialNumber}</div>`
: nothing
}
${
matchingIntegrations
? html`<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.serial.can_be_used_with",
{
integrations: matchingIntegrations,
}
)}
</div>`
: nothing
}
${
item.port.present
? html`<ha-icon-button
slot="end"
.label=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
.path=${mdiInformationOutline}
.port=${item.port}
@click=${this._showPortInfo}
></ha-icon-button>`
: nothing
}
</ha-md-list-item>
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
`;
}
private _showPortInfo(ev: Event): void {
showSerialPortInfoDialog(this, {
port: (ev.currentTarget as any).port,
});
}
private _renderPortsCard(
header: string,
description: string,
items: PortListItem[]
): TemplateResult {
return html`
<ha-card class="ports">
<div class="card-header">${header}</div>
<div class="card-content">
<div class="description">${description}</div>
<ha-md-list>
${items.map((item) => this._renderPortItem(item))}
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderStatusCard(
inUse: number,
available: number,
disconnected: number
): TemplateResult {
const status = disconnected ? "disconnected" : "ok";
let summary = this.hass.localize("ui.panel.config.serial.status_summary", {
in_use: inUse,
total: inUse + available,
});
if (disconnected) {
summary += ` · ${this.hass.localize(
"ui.panel.config.serial.status_summary_disconnected",
{ count: disconnected }
)}`;
}
return html`
<ha-card class="status">
<div class="card-content">
<div class="heading">
<div class="icon ${status}">
<ha-svg-icon
.path=${disconnected ? mdiAlertCircleOutline : mdiCheck}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(`ui.panel.config.serial.status_${status}`)}
<br />
<small>${summary}</small>
</div>
<ha-svg-icon class="logo" .path=${mdiCableData}></ha-svg-icon>
</div>
</div>
</ha-card>
`;
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.serial.title")}
back-path="/config"
>
<ha-icon-button
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.refresh")}
.path=${mdiRefresh}
@click=${this._handleRefresh}
></ha-icon-button>
<div class="container">${this._renderContent()}</div>
</hass-subpage>
`;
}
private _renderContent(): TemplateResult {
if (this._error !== undefined) {
return html`
<ha-alert
alert-type="error"
.title=${this.hass.localize("ui.panel.config.serial.loading_error")}
>
${this._error}
</ha-alert>
`;
}
if (!this._ports) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
const { available, connected, disconnected } = this._sortedPorts(
this._ports,
this.hass.localize,
this.hass.locale.language
);
if (!available.length && !connected.length && !disconnected.length) {
return html`
<ha-card>
<div class="card-content">
<div class="empty">
${this.hass.localize("ui.panel.config.serial.no_ports")}
</div>
</div>
</ha-card>
`;
}
return html`
${this._renderStatusCard(
connected.length,
available.length,
disconnected.length
)}
${
connected.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.connected"),
this.hass.localize(
"ui.panel.config.serial.connected_description"
),
connected
)
: nothing
}
${
available.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.available"),
this.hass.localize(
"ui.panel.config.serial.available_description"
),
available
)
: nothing
}
${
disconnected.length
? this._renderPortsCard(
this.hass.localize("ui.panel.config.serial.disconnected"),
this.hass.localize(
"ui.panel.config.serial.disconnected_description"
),
disconnected
)
: nothing
}
`;
}
private _handleRefresh(): void {
this._fetchPorts();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card,
ha-alert {
display: block;
margin: 0px auto var(--ha-space-4);
max-width: 600px;
}
ha-card:first-child {
margin-top: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12) 0;
}
ha-card.ports {
overflow: hidden;
}
ha-card.ports .card-content {
padding: 0;
}
ha-card.ports .card-header {
padding-bottom: var(--ha-space-2);
}
.status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.status div.heading .logo {
margin-inline-start: auto;
--mdc-icon-size: 40px;
}
.status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.status div.heading .icon.ok {
--icon-color: var(--success-color);
}
.status div.heading .icon.disconnected {
--icon-color: var(--warning-color);
}
.status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 0;
--md-list-item-two-line-container-height: 0;
--md-list-item-three-line-container-height: 0;
}
ha-md-list-item.port:not(:first-child) {
border-top: 1px solid var(--divider-color);
margin-top: var(--ha-space-2);
--md-list-item-top-space: var(--ha-space-4);
}
ha-md-list-item .disconnected {
color: var(--disabled-text-color);
}
ha-md-list-item.consumer {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.consumer img[slot="start"] {
width: 24px;
height: 24px;
}
.description {
padding: 0 var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-4);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"serial-config-dashboard": SerialConfigDashboard;
}
}
@@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { SerialPortUsage } from "../../../../../data/usb";
export interface SerialPortInfoDialogParams {
port: SerialPortUsage;
}
export const loadSerialPortInfoDialog = () =>
import("./dialog-serial-port-info");
export const showSerialPortInfoDialog = (
element: HTMLElement,
serialPortInfoDialogParams: SerialPortInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-serial-port-info",
dialogImport: loadSerialPortInfoDialog,
dialogParams: serialPortInfoDialogParams,
});
};
@@ -306,6 +306,7 @@ class DialogZWaveJSAddNode extends LitElement {
if (this._step === "choose_security_strategy") {
return html`<zwave-js-add-node-select-security-strategy
.hass=${this.hass}
.inclusionStrategy=${this._inclusionStrategy}
@z-wave-strategy-selected=${this._setSecurityStrategy}
></zwave-js-add-node-select-security-strategy>`;
}
@@ -1,4 +1,4 @@
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
@@ -14,7 +14,7 @@ import "../../../../../../components/ha-form/ha-form";
export class ZWaveJsAddNodeSelectMethod extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _inclusionStrategy?: InclusionStrategy;
@property({ attribute: false }) public inclusionStrategy?: InclusionStrategy;
private _getSchema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [
{
@@ -62,7 +62,7 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
return html`
<ha-form
.schema=${this._getSchema(this.hass.localize)}
.data=${{ strategy: this._inclusionStrategy?.toString() }}
.data=${{ strategy: this.inclusionStrategy?.toString() }}
@value-changed=${this._selectStrategy}
.computeLabel=${this._computeLabel}
>
@@ -13,7 +13,7 @@ import "../../../components/ha-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog-footer";
import "./dialog-repairs-issue-subtitle";
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import type { RepairsIssue } from "../../../data/repairs";
import { ignoreRepairsIssue } from "../../../data/repairs";
@@ -20,7 +20,7 @@ import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fixStatisticsIssue } from "../tools/statistics/fix-statistics";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
import { showRepairsFlowDialog } from "../../../dialogs/repairs-flow/show-dialog-repair-flow";
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
@customElement("ha-config-repairs")

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