Compare commits

..

117 Commits

Author SHA1 Message Date
Paul Bottein
134681b4c9 Merge branch 'dev' into toggle_group_dialog 2025-07-10 18:50:14 +02:00
Paul Bottein
082f1ca55e Center content on mobile 2025-07-10 18:49:05 +02:00
Norbert Rittel
3b7d2869e5 Fix sentence-casing of two "More Info" button labels (#26135)
Fix sentence-casing of two "More Info" buttons

- the one in the Dev tools opens the "More info" dialog for the entity, so it's changed to that dialog's name
- the one for Thread configuration opens href=${documentationUrl(this.hass, `/integrations/thread`)}
therefore it's changed to "More information"
2025-07-10 16:07:11 +00:00
renovate[bot]
bcda5cd0cf Update dependency core-js to v3.44.0 (#26134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 16:02:35 +00:00
renovate[bot]
eeb64a25ff Update dependency @rsdoctor/rspack-plugin to v1.1.8 (#26133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 16:01:56 +00:00
Petar Petrov
9134132ba9 Only show loading for slow flow steps to avoid flickering (#26131) 2025-07-10 17:59:07 +02:00
Paul Bottein
341e63e878 Fix device class icon off state 2025-07-10 17:36:03 +02:00
Paul Bottein
5ed2d2fd2f Fix last updated 2025-07-10 17:33:15 +02:00
Paul Bottein
c6f92d1375 Add translations 2025-07-10 17:26:36 +02:00
Paul Bottein
e8201f7848 Use variable 2025-07-10 15:59:53 +02:00
Paul Bottein
6d7df18e82 Fix available entities and header 2025-07-10 15:58:09 +02:00
Paul Bottein
1471cfea66 Don't use new colors for now 2025-07-10 15:33:29 +02:00
Paul Bottein
9e4835107d Merge dialog with more info 2025-07-10 14:43:46 +02:00
karwosts
1ded254e5a Fix some weather-forecast card editor issues (#26125) 2025-07-10 11:27:37 +03:00
Christoph
fc104a7992 add floor column to datatable in config devices page (#26103)
* add floor column to datatable in config devices page

* refactor conditions related to floor column in config devices page
2025-07-10 11:25:56 +03:00
Paul Bottein
3269fd3c5b Feedbacks 2025-07-09 18:14:29 +02:00
Paul Bottein
17e63343c7 Handle multiple entities 2025-07-09 16:52:58 +02:00
karwosts
e7e062a222 Pause map autofit when user initiates pan/zoom (#26114)
* Pause map autofit when user initiates pan/zoom

* not a state

* a different approach
2025-07-09 17:32:20 +03:00
Franck Nijhof
5233086efb Add Task issue form (#26121) 2025-07-09 14:14:37 +02:00
Christoph
8d95f0d95d add unit tests for common/url/search-params.ts (#26115) 2025-07-09 14:11:28 +03:00
karwosts
5cf8b39703 Coerce all energy distribution values to the same unit (#26117) 2025-07-09 14:06:47 +03:00
Franck Nijhof
15dabe372c Adjust feature request links in issue reporting (#26123) 2025-07-09 12:40:37 +02:00
renovate[bot]
aab52a8bb2 Update dependency vis-data to v7.1.10 (#26122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 10:27:49 +00:00
Paul Bottein
dc7ba0dac6 Fix dialog at the top 2025-07-09 10:02:12 +02:00
Paul Bottein
2ab4608884 Delete dashboard dialog 2025-07-09 09:49:44 +02:00
Paul Bottein
de7f5c1bb7 Add toggle group dialog 2025-07-08 19:18:13 +02:00
Paul Bottein
7144b7802e Invert order 2025-07-08 15:06:59 +02:00
Norbert Rittel
aa52825b40 Capitalize "REST", remove excessive commas (#26109) 2025-07-08 12:57:30 +02:00
Christoph
2809a306e6 do not set "___ADD_NEW___" value in ha-floor-picker (#26102) 2025-07-08 12:40:24 +02:00
Paul Bottein
ca315b88ce Add sections dialog 2025-07-08 12:35:51 +02:00
renovate[bot]
a6304d6284 Update rspack monorepo to v1.4.4 (#26105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 08:50:51 +03:00
Paul Bottein
8e866e86d6 Use query params instead of path for media browser navigate ids (#26099) 2025-07-08 08:50:28 +03:00
Kevin Lakotko
2e8203f666 Sort groups if same as sort column (#26010)
* fix(grouping): if sorted by column sort group

* chore: use props to group for memoization
2025-07-07 19:23:27 +03:00
Paulus Schoutsen
b60f2e3201 Add extra margin AI Task pref (#26096)
Add extra margin AI Task
2025-07-07 12:11:35 +03:00
renovate[bot]
c5f57f436c Update rspack monorepo to v1.4.3 (#26093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 10:17:53 +03:00
steinmn
3bb930b906 Fix flickering Edit sidebar dialog by locking content padding (#26084)
Fix flickering Edit sidebar dialog
2025-07-07 05:29:25 +00:00
Ezra Freedman
e75331e159 Weather card smallest width is not set correctly (#26082)
set result.width, not result.height
2025-07-06 10:12:59 +02:00
renovate[bot]
d6b66a7145 Update dependency @rsdoctor/rspack-plugin to v1.1.7 (#26087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 08:07:11 +00:00
Yosi Levy
5c346798c8 RTL fixes for 7-25 (#26074) 2025-07-06 10:04:09 +02:00
renovate[bot]
5ffe37407a Update dependency hls.js to v1.6.6 (#26085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 09:58:49 +02:00
renovate[bot]
2b056c0434 Update dependency @lokalise/node-api to v14.9.1 (#26081)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 09:58:06 +02:00
Paulus Schoutsen
27b36707e5 Automation save dialog to suggest name, description and labels (#26071)
* AI Task structure

* Suggest description and labels too
2025-07-06 09:57:16 +02:00
renovate[bot]
5760614b65 Update babel monorepo to v7.28.0 (#26079)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-05 12:17:24 +02:00
renovate[bot]
3835912b01 Update dependency @rsdoctor/rspack-plugin to v1.1.6 (#26078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-05 12:14:01 +02:00
Petar Petrov
a385655c85 Remove deprecated dependency @types/glob (#26075) 2025-07-05 08:27:41 +02:00
karwosts
e177012108 Fix default range icon (#26069) 2025-07-04 23:34:38 +02:00
renovate[bot]
cc3234ad8f Update dependency eslint to v9.30.1 (#26072)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 22:47:44 +02:00
karwosts
4d932f0b4a Support translating number selector UoM (#26070) 2025-07-04 21:06:49 +03:00
renovate[bot]
257769cdc7 Update dependency globals to v16.3.0 (#26068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 18:42:09 +02:00
renovate[bot]
6619f064eb Update dependency @lokalise/node-api to v14.9.0 (#26067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 15:12:03 +02:00
renovate[bot]
382a47a082 Update rspack monorepo to v1.4.2 (#26066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 15:11:40 +02:00
renovate[bot]
ad4be75fe1 Update dependency typescript-eslint to v8.35.1 (#26058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 10:16:33 +03:00
Ezra Freedman
d605b67b41 Prevent uncaught TypeError on HuiWeatherForecastCard render (#26038) 2025-07-03 21:19:48 +02:00
renovate[bot]
dba6a3c756 Update fullcalendar monorepo to v6.1.18 (#26047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 17:14:49 +03:00
c0ffeeca7
002e9ad071 Terminology: change controller to adapter (#26051)
* Terminology: change controller to adapter

* Update src/translations/en.json

Co-authored-by: AlCalzone <d.griesel@gmx.net>

* Apply suggestions from code review

---------

Co-authored-by: AlCalzone <d.griesel@gmx.net>
2025-07-03 15:51:35 +02:00
Paul Bottein
6e7874c2c9 Fix play media action (#26035) 2025-07-02 19:30:06 +02:00
Paul Bottein
978f9b0f83 Reduce media selector size (#26033) 2025-07-02 18:08:31 +02:00
renovate[bot]
2b88669a72 Update dependency eslint-plugin-lit-a11y to v5.1.0 (#26020)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 17:20:13 +02:00
renovate[bot]
252fd2bb6c Update dependency @bundle-stats/plugin-webpack-filter to v4.21.0 (#26032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 17:19:41 +02:00
Paul Bottein
cc68a087a2 Fix zoom in statistic chart (#26034) 2025-07-02 17:18:39 +02:00
karwosts
3e1341a731 Fix glitchy 'show' checkboxes on integration page (#26021) 2025-07-02 13:43:06 +02:00
Bram Kragten
6be25270fd Dont fetch device actions on first updated (#26028) 2025-07-02 13:42:21 +02:00
Bram Kragten
ce929aea46 Disable fullscreen in trigger detail dialog (#26030) 2025-07-02 13:41:52 +02:00
Paul Bottein
8853bf6ea2 Improve styling of the code editor in fullscreen mode (#26029) 2025-07-02 13:41:26 +02:00
Paul Bottein
2241807745 Fix UI jump when using drag and drop in areas strategy editor (#26026) 2025-07-02 09:03:21 +00:00
Paul Bottein
50d705c943 Add missing domain icon import in area controls (#26023) 2025-07-01 21:47:20 +02:00
Paul Bottein
eb111d3c32 Add missing area helper (#26022) 2025-07-01 21:46:58 +02:00
Paul Bottein
1e59f9f4be Increase target area in tile card and area card (#26017) 2025-07-01 14:34:19 +02:00
Paul Bottein
523eb9522f Add dashboard title to strategy editor (#26015) 2025-07-01 14:33:36 +02:00
Paul Bottein
f6cb322819 Avoid selector to take to much space in action calls (#26014) 2025-07-01 14:32:54 +02:00
Paul Bottein
4f97756f4e Force narrow style for action, condition and trigger in blueprint (#26018) 2025-07-01 15:22:55 +03:00
Ezra Freedman
8644dd5271 Fix translation in the integration page for entities (#26009)
add call to localize
2025-07-01 06:48:13 +00:00
renovate[bot]
26d842f432 Update dependency @babel/helper-define-polyfill-provider to v0.6.5 (#26008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:34:48 +02:00
renovate[bot]
ad4f14ffaf Update dependency eslint to v9.30.0 (#26012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:31:30 +02:00
Paul Bottein
948c858e78 Fix object selector not displayed (#26007) 2025-06-30 16:15:37 +00:00
Paul Bottein
49099223d3 Do not display quality scale for custom integrations (#26006) 2025-06-30 16:10:08 +00:00
Paul Bottein
0fbd430594 Allow to re-order floors in areas dashboard (#26002)
* Allow to re-order floors in areas dashboard

* Move drag handle to right

* Improve typings

* Only show drag handle if there is at least 2 floors
2025-06-30 16:09:42 +00:00
Kevin Lakotko
8cc762d839 Fix use of numeric option for collator (#25917)
* fix(string): use numeric option for collator

* test: add natural sort comparison tests
2025-06-30 18:00:45 +02:00
Paul Bottein
89d9dd2893 Improve device row in integration page (#26005)
Improve device row in config entry page
2025-06-30 17:44:50 +02:00
renovate[bot]
b7d1ce1c37 Update rspack monorepo to v1.4.1 (#26001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:22:39 +02:00
renovate[bot]
869d10ca3f Update CodeMirror (#26003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:22:09 +02:00
Simon Lamon
f338089148 Pass area control service calls through hass (#25986)
Connection logging
2025-06-30 14:59:15 +02:00
renovate[bot]
06b0f9fcaf Update dependency @rsdoctor/rspack-plugin to v1.1.5 (#26000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 13:49:05 +02:00
Simon Lamon
7ad07e4c55 Fix fullscreen yaml editor (transparency background) (#25989)
Fix fullscreen editor (transparency background)
2025-06-30 14:16:59 +03:00
renovate[bot]
ad65600d11 Update dependency marked to v16 (#25997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 14:15:16 +03:00
renovate[bot]
e91d907e56 Update dependency prettier to v3.6.2 (#25996) 2025-06-30 08:26:28 +02:00
renovate[bot]
b35a1fc9e0 Update dependency @babel/core to v7.27.7 (#25992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 20:54:40 +03:00
renovate[bot]
dd18ad96f3 Update dependency gulp-rename to v2.1.0 (#25985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 17:16:40 +02:00
Norbert Rittel
62eec56e5f Fix grammar of Light, Sensor and Tile card descriptions (#25988)
* Fix grammar of Light, Sensor and Entity card descriptions

* Capitalize "Tile card" as a name

* Apply same change to Entity badge description
2025-06-29 17:16:09 +02:00
renovate[bot]
7187e25cad Update rspack monorepo to v1.4.0 (#25987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 11:06:34 +02:00
Norbert Rittel
6d9e6a616d Fix sentence-casing, spelling and grammar issues (#25981)
* Fix sentence-casing, spelling and grammar issues

* Add "IP information" to the list

* More sentence-casing issues
2025-06-29 09:25:29 +02:00
renovate[bot]
44d87e3c66 Update dependency barcode-detector to v3.0.5 (#25980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 17:41:15 +02:00
renovate[bot]
085e2460bc Update dependency prettier to v3.6.1 (#25978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 17:40:47 +02:00
karwosts
1f8a9e4caf Improve settings page accessibility (No. 2) (#25965) 2025-06-27 19:38:11 +02:00
Franck Nijhof
f08877437e Add initial instructions file for GitHub Copilot and Claude Code (#25967) 2025-06-27 18:06:23 +02:00
renovate[bot]
6690d1ef22 Update dependency @types/leaflet to v1.9.19 (#25974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 17:54:52 +02:00
Paul Bottein
9d8a5b366e Use entity format state if only one entity for that domain in the area card (#25964)
Use entity format state if only one entity is area card
2025-06-27 17:41:58 +02:00
Franck Nijhof
22c798c9d6 Add Claude to gitignore (#25966) 2025-06-27 15:59:14 +02:00
Norbert Rittel
8aabb1f32f Dev Tools: Remove excessive space from "Input date times" (#25973)
Remove excessive space from "input date times"
2025-06-27 15:57:52 +02:00
renovate[bot]
33d5cecc85 Update dependency ua-parser-js to v2.0.4 (#25968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 15:12:14 +03:00
Paul Bottein
7693a4dc24 Use areas dashboard name in the top bar (#25969) 2025-06-27 10:40:15 +02:00
Paul Bottein
9ec38c7dd9 Bump vaadin to 24.7.9 (#25963) 2025-06-26 21:22:06 +00:00
Bram Kragten
e8cb85f7ff Disable fullscreen editor for editors that are already fullscreen (#25959)
* Disabled fullscreen editor for editors that are already fullscreen

* Update ha-code-editor.ts
2025-06-26 23:17:30 +02:00
renovate[bot]
ef964a2717 Update dependency typescript-eslint to v8.35.0 (#25956) 2025-06-26 19:32:39 +02:00
Paul Bottein
369881f8a6 Fix expand icon for entries and sub entries (#25955) 2025-06-26 19:21:15 +02:00
Bram Kragten
68e22d23f1 Fix filtering on device in entities config panel (#25948)
* Fix filtering on device in entities config panel

* fix

* set filters from url twice to catch race...
2025-06-26 16:42:11 +02:00
Paul Bottein
696ba69a9e Revert vaadin to 24.7.7 (#25953) 2025-06-26 14:41:58 +00:00
Paul Bottein
e2ab52e10e Don't limit combo-box dropdown size (#25952) 2025-06-26 14:12:07 +00:00
Bram Kragten
b154bc1502 Load title when fetching flow (#25951) 2025-06-26 14:07:46 +00:00
Paul Bottein
a952b880d8 Disable escape key to close edit card dialog (#25947) 2025-06-26 15:25:26 +02:00
Bram Kragten
018aceb542 Add label to version number (#25942)
Add label
2025-06-26 15:38:33 +03:00
Bram Kragten
2fb86f118e make sure header is always shown in data entry flow (#25941) 2025-06-26 11:07:08 +00:00
Bram Kragten
6c8caccfec Use different icon for services (#25939) 2025-06-26 13:05:28 +02:00
Paul Bottein
3dd3a80054 Remove alert classes and only use slot sensors for areas dashboard (#25937)
* Remove alert classes and only used slot sensors for areas dashboard

* Rename group to sensors

* Rename group to sensors
2025-06-26 13:05:05 +02:00
Bram Kragten
675310afdf add version number to integration page (#25940)
* add version number to integration page

* Update ha-config-integration-page.ts
2025-06-26 11:04:13 +00:00
Paul Bottein
f5df91d4c7 Better handle case when no floors in areas dashboard (#25933) 2025-06-26 12:18:34 +02:00
Bram Kragten
d8ab9b73ba Prevent overflow of ripple on device row on integration page (#25922) 2025-06-26 12:17:00 +02:00
88 changed files with 3288 additions and 1502 deletions

View File

@@ -11,7 +11,7 @@ body:
**Please do not report issues for custom cards.**
[fr]: https://github.com/home-assistant/frontend/discussions
[fr]: https://github.com/orgs/home-assistant/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
- type: checkboxes
attributes:

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Request a feature for the UI / Dashboards
url: https://github.com/home-assistant/frontend/discussions/category_choices
url: https://github.com/orgs/home-assistant/discussions
about: Request a new feature for the Home Assistant frontend.
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues

53
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Task
description: For staff only - Create a task
type: Task
body:
- type: markdown
attributes:
value: |
## ⚠️ RESTRICTED ACCESS
**This form is restricted to Open Home Foundation staff and authorized contributors only.**
If you are a community member wanting to contribute, please:
- For bug reports: Use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
---
### For authorized contributors
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
- type: textarea
id: description
attributes:
label: Description
description: |
Provide a clear and detailed description of the task that needs to be accomplished.
Be specific about what needs to be done, why it's important, and any constraints or requirements.
placeholder: |
Describe the task, including:
- What needs to be done
- Why this task is needed
- Expected outcome
- Any constraints or requirements
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: |
Any additional information, links, research, or context that would be helpful.
Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: |
- Roadmap opportunity: [link]
- Epic: [link]
- Feature request: [link]
- Technical design documents: [link]
- Prototype/mockup: [link]
- Dependencies: [links]
validations:
required: false

592
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,592 @@
# GitHub Copilot & Claude Code Instructions
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
## Table of Contents
- [Quick Reference](#quick-reference)
- [Core Architecture](#core-architecture)
- [Development Standards](#development-standards)
- [Component Library](#component-library)
- [Common Patterns](#common-patterns)
- [Text and Copy Guidelines](#text-and-copy-guidelines)
- [Development Workflow](#development-workflow)
- [Review Guidelines](#review-guidelines)
## Quick Reference
### Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler
yarn test # Vitest
script/develop # Development server
```
### Component Prefixes
- `ha-` - Home Assistant components
- `hui-` - Lovelace UI components
- `dialog-` - Dialog components
### Import Patterns
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
```
## Core Architecture
The Home Assistant frontend is a modern web application that:
- Uses Web Components (custom elements) built with Lit framework
- Is written entirely in TypeScript with strict type checking
- Communicates with the backend via WebSocket API
- Provides comprehensive theming and internationalization
## Development Standards
### Code Quality Requirements
**Linting and Formatting (Enforced by Tools)**
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
**Naming Conventions**
- PascalCase for types and classes
- camelCase for variables, methods
- Private methods require leading underscore
- Public methods forbid leading underscore
### TypeScript Usage
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
- **Proper type imports**: Use `import type` for type-only imports
- **Define interfaces**: Create proper interfaces for data structures
- **Type component properties**: All Lit properties must be properly typed
- **No unused variables**: Prefix with `_` if intentionally unused
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
```typescript
// Good
import type { HomeAssistant } from "../types";
interface EntityConfig {
entity: string;
name?: string;
}
@property({ type: Object })
hass!: HomeAssistant;
// Bad
@property()
hass: any;
```
### Web Components with Lit
- **Use Lit 3.x patterns**: Follow modern Lit practices
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
- **Define custom element names**: Use `ha-` prefix for components
```typescript
@customElement("ha-my-component")
export class HaMyComponent extends LitElement {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyComponentConfig;
static get styles() {
return css`
:host {
display: block;
}
`;
}
render() {
return html`<div>Content</div>`;
}
}
```
### Component Guidelines
- **Use composition**: Prefer composition over inheritance
- **Lazy load panels**: Heavy panels should be dynamically imported
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
- **Handle loading states**: Always show appropriate loading indicators
- **Support themes**: Use CSS custom properties from theme
### Data Management
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
- **Cache appropriately**: Use collections and caching for frequently accessed data
- **Handle errors gracefully**: All API calls should have error handling
- **Update real-time**: Subscribe to state changes for live updates
```typescript
// Good
try {
const result = await fetchEntityRegistry(this.hass.connection);
this._processResult(result);
} catch (err) {
showAlertDialog(this, {
text: `Failed to load: ${err.message}`,
});
}
```
### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system
- **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
static get styles() {
return css`
:host {
--spacing: 16px;
padding: var(--spacing);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
@media (max-width: 600px) {
:host {
--spacing: 8px;
}
}
`;
}
```
### Performance Best Practices
- **Code split**: Split code at the panel/dialog level
- **Lazy load**: Use dynamic imports for heavy components
- **Optimize bundle**: Keep initial bundle size minimal
- **Use virtual scrolling**: For long lists, implement virtual scrolling
- **Memoize computations**: Cache expensive calculations
### Testing Requirements
- **Write tests**: Add tests for data processing and utilities
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
## Component Library
### Dialog Components
**Available Dialog Types:**
- `ha-md-dialog` - Preferred for new code (Material Design 3)
- `ha-dialog` - Legacy component still widely used
**Opening Dialogs (Fire Event Pattern - Recommended):**
```typescript
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
**Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface
- Use `createCloseHeading()` for standard headers
- Import `haStyleDialog` for consistent styling
- Return `nothing` when no params (loading state)
- Fire `dialog-closed` event when closing
- Add `dialogInitialFocus` for accessibility
````
### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
- Use `dialogInitialFocus` in dialogs
- Use `computeLabel`, `computeError`, `computeHelper` for translations
```typescript
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
````
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
- Content announced by screen readers when dynamically displayed
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
## Common Patterns
### Creating a Panel
```typescript
@customElement("ha-panel-myfeature")
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
@property({ attribute: false })
hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
narrow!: boolean;
@property()
route!: Route;
hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entities) => {
this._entities = entities;
}),
];
}
}
```
### Creating a Dialog
```typescript
@customElement("dialog-my-feature")
export class DialogMyFeature
extends LitElement
implements HassDialog<MyDialogParams>
{
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _params?: MyDialogParams;
public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params;
}
public closeDialog(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, this._params.title)}
>
<!-- Dialog content -->
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
`;
}
static styles = [haStyleDialog, css``];
}
```
### Dialog Design Guidelines
- Max width: 560px (Alert/confirmation: 320px fixed width)
- Close X-icon on top left (all screen sizes)
- Submit button grouped with cancel at bottom right
- Keep button labels short: "Save", "Delete", "Enable"
- Destructive actions use red warning button
- Always use a title (best practice)
- Strive for minimalism
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
```typescript
@customElement("hui-my-card")
export class HuiMyCard extends LitElement implements LovelaceCard {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyCardConfig;
public setConfig(config: MyCardConfig): void {
if (!config.entity) {
throw new Error("Entity required");
}
this._config = config;
}
public getCardSize(): number {
return 3; // Height in grid units
}
// Optional: Editor for card configuration
public static getConfigElement(): LovelaceCardEditor {
return document.createElement("hui-my-card-editor");
}
// Optional: Stub config for card picker
public static getStubConfig(): object {
return { entity: "" };
}
}
```
**Card Guidelines:**
- Cards are highly customizable for different households
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
- Use proper error handling in `setConfig()`
- Consider all possible states (loading, error, unavailable)
- Support different entity types and states
- Follow responsive design principles
- Add configuration editor when needed
### Internationalization
- **Use localize**: Always use the localization system
- **Add translation keys**: Add keys to src/translations/en.json
- **Support placeholders**: Use proper placeholder syntax
```typescript
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
### Accessibility
- **ARIA labels**: Add appropriate ARIA labels
- **Keyboard navigation**: Ensure all interactions work with keyboard
- **Screen reader support**: Test with screen readers
- **Color contrast**: Meet WCAG AA standards
## Development Workflow
### Setup and Commands
1. **Setup**: `script/setup` - Install dependencies
2. **Develop**: `script/develop` - Development server
3. **Lint**: `yarn lint` - Run all linting before committing
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Common Pitfalls to Avoid
- Don't use `querySelector` - Use refs or component properties
- Don't manipulate DOM directly - Let Lit handle rendering
- Don't use global styles - Scope styles to components
- Don't block the main thread - Use web workers for heavy computation
- Don't ignore TypeScript errors - Fix all type issues
### Security Best Practices
- Sanitize HTML - Never use `unsafeHTML` with user content
- Validate inputs - Always validate user inputs
- Use HTTPS - All external resources must use HTTPS
- CSP compliance - Ensure code works with Content Security Policy
### Text and Copy Guidelines
#### Terminology Standards
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
- **Use "Remove"** for actions that can be restored or reapplied:
- Removing a user's permission
- Removing a user from a group
- Removing links between items
- Removing a widget from dashboard
- Removing an item from a cart
- **Use "Delete"** for permanent, non-recoverable actions:
- Deleting a field
- Deleting a value in a field
- Deleting a task
- Deleting a group
- Deleting a permission
- Deleting a calendar event
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
- **Use "Add"** for already-existing items:
- Adding a permission to a user
- Adding a user to a group
- Adding links between items
- Adding a widget to dashboard
- Adding an item to a cart
- **Use "Create"** for something made from scratch:
- Creating a new field
- Creating a new task
- Creating a new group
- Creating a new permission
- Creating a new calendar event
#### Writing Style (Consistent with Home Assistant Documentation)
- **Use American English**: Standard spelling and terminology
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
- **Address users directly**: Use "you" and "your"
- **Be inclusive**: Objective, non-discriminatory language
- **Be concise**: Use clear, direct language
- **Be consistent**: Follow established terminology patterns
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
- **Avoid jargon**: Use terms familiar to home automation users
#### Language Standards
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
- **Avoid abbreviations**: Spell out terms when possible
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
- ✅ "Create new automation"
- ❌ "Create New Automation"
- ✅ "Device settings"
- ❌ "Device Settings"
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
- **Avoid CAPS for emphasis**: Use bold or italics instead
- **Write for all skill levels**: Both technical and non-technical users
#### Key Terminology
- **"add-on"** (hyphenated, not "addon")
- **"integration"** (preferred over "component")
- **Technical terms**: Use lowercase (automation, entity, device, service)
#### Translation Considerations
- **Add translation keys**: All user-facing text must be translatable
- **Use placeholders**: Support dynamic content in translations
- **Keep context**: Provide enough context for translators
```typescript
// Good
this.hass.localize("ui.panel.config.automation.delete_confirm", {
name: automation.alias,
});
// Bad - hardcoded text
("Are you sure you want to delete this automation?");
```
### Common Review Issues (From PR Analysis)
#### User Experience and Accessibility
- **Form validation**: Always provide proper field labels and validation feedback
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
- **Loading states**: Show clear progress indicators during async operations
- **Error handling**: Display meaningful error messages when operations fail
- **Mobile responsiveness**: Ensure components work well on small screens
- **Hit targets**: Make clickable areas large enough for touch interaction
- **Visual feedback**: Provide clear indication of interactive states
#### Dialog and Modal Patterns
- **Dialog width constraints**: Respect minimum and maximum width requirements
- **Interview progress**: Show clear progress for multi-step operations
- **State persistence**: Handle dialog state properly during background operations
- **Cancel behavior**: Ensure cancel/close buttons work consistently
- **Form prefilling**: Use smart defaults but allow user override
#### Component Design Patterns
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
- **Grid alignment**: Components should align to the design grid system
- **Badge placement**: Position badges and indicators consistently
- **Color theming**: Respect theme variables and design system colors
#### Code Quality Issues
- **Null checking**: Always check if entities exist before accessing properties
- **TypeScript safety**: Handle potentially undefined array/object access
- **Import organization**: Remove unused imports and use proper type imports
- **Event handling**: Properly subscribe and unsubscribe from events
- **Memory leaks**: Clean up subscriptions and event listeners
#### Configuration and Props
- **Optional parameters**: Make configuration fields optional when sensible
- **Smart defaults**: Provide reasonable default values
- **Future extensibility**: Design APIs that can be extended later
- **Validation**: Validate configuration before applying changes
## Review Guidelines
### Core Requirements Checklist
- [ ] TypeScript strict mode passes (`yarn lint:types`)
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
- [ ] Prettier formatting applied (`yarn lint:prettier`)
- [ ] Lit analyzer passes (`yarn lint:lit`)
- [ ] Component follows Lit best practices
- [ ] Proper error handling implemented
- [ ] Loading states handled
- [ ] Mobile responsive
- [ ] Theme variables used
- [ ] Translations added
- [ ] Accessible to screen readers
- [ ] Tests added (where applicable)
- [ ] No console statements (use proper logging)
- [ ] Unused imports removed
- [ ] Proper naming conventions
### Text and Copy Checklist
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
- [ ] Localization keys added for all user-facing text
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
- [ ] American English spelling
- [ ] Friendly, informational tone
- [ ] Avoids abbreviations and jargon
- [ ] Correct terminology (add-on not addon, integration not component)
### Component-Specific Checks
- [ ] Dialogs implement HassDialog interface
- [ ] Dialog styling uses haStyleDialog
- [ ] Dialog accessibility includes dialogInitialFocus
- [ ] ha-alert used correctly for messages
- [ ] ha-form uses proper schema structure
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event subscriptions properly cleaned up

View File

@@ -0,0 +1,58 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.issue_type == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});

4
.gitignore vendored
View File

@@ -53,3 +53,7 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# AI tooling
.claude

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -30,11 +30,11 @@
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.1",
"@codemirror/language": "6.11.2",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.37.2",
"@codemirror/view": "6.38.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -45,12 +45,12 @@
"@formatjs/intl-numberformat": "8.15.4",
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
"@fullcalendar/interaction": "6.1.17",
"@fullcalendar/list": "6.1.17",
"@fullcalendar/luxon3": "6.1.17",
"@fullcalendar/timegrid": "6.1.17",
"@fullcalendar/core": "6.1.18",
"@fullcalendar/daygrid": "6.1.18",
"@fullcalendar/interaction": "6.1.18",
"@fullcalendar/list": "6.1.18",
"@fullcalendar/luxon3": "6.1.18",
"@fullcalendar/timegrid": "6.1.18",
"@lezer/highlight": "1.2.1",
"@lit-labs/motion": "1.0.8",
"@lit-labs/observers": "2.0.5",
@@ -89,17 +89,17 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.8.0",
"@vaadin/vaadin-themable-mixin": "24.8.0",
"@vaadin/combo-box": "24.7.9",
"@vaadin/vaadin-themable-mixin": "24.7.9",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "3.0.4",
"barcode-detector": "3.0.5",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.43.0",
"core-js": "3.44.0",
"cropperjs": "1.6.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.5",
"hls.js": "1.6.6",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -122,7 +122,7 @@
"lit": "3.3.0",
"lit-html": "3.3.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked": "16.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -135,8 +135,8 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"vis-data": "7.1.9",
"ua-parser-js": "2.0.4",
"vis-data": "7.1.10",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -149,26 +149,25 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.27.4",
"@babel/helper-define-polyfill-provider": "0.6.4",
"@babel/plugin-transform-runtime": "7.27.4",
"@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.20.2",
"@lokalise/node-api": "14.8.0",
"@babel/core": "7.28.0",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/preset-env": "7.28.0",
"@bundle-stats/plugin-webpack-filter": "4.21.0",
"@lokalise/node-api": "14.9.1",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.4",
"@rspack/cli": "1.3.12",
"@rspack/core": "1.3.12",
"@rsdoctor/rspack-plugin": "1.1.8",
"@rspack/cli": "1.4.4",
"@rspack/core": "1.4.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.18",
"@types/leaflet": "1.9.19",
"@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
@@ -184,13 +183,13 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.29.0",
"eslint": "9.30.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit-a11y": "5.0.1",
"eslint-plugin-lit-a11y": "5.1.0",
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "3.0.1",
"fancy-log": "2.0.0",
@@ -199,7 +198,7 @@
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "26.1.0",
@@ -210,7 +209,7 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.6.0",
"prettier": "3.6.2",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "21.0.0",
@@ -218,7 +217,7 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3",
@@ -231,8 +230,8 @@
"lit-html": "3.3.0",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17",
"globals": "16.2.0",
"@fullcalendar/daygrid": "6.1.18",
"globals": "16.3.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},

View File

@@ -202,7 +202,6 @@ export function storage(options: {
// Don't set the initial value if we have a value in localStorage
if (this.__initialized || getValue() === undefined) {
setValue(this, value);
this.requestUpdate(propertyKey, undefined);
}
},
configurable: true,
@@ -212,11 +211,13 @@ export function storage(options: {
const oldSetter = descriptor.set;
newDescriptor = {
...descriptor,
get(this: ReactiveStorageElement) {
return getValue();
},
set(this: ReactiveStorageElement, value) {
// Don't set the initial value if we have a value in localStorage
if (this.__initialized || getValue() === undefined) {
setValue(this, value);
this.requestUpdate(propertyKey, undefined);
}
oldSetter?.call(this, value);
},

View File

@@ -1,4 +1,4 @@
import { callService, type HassEntity } from "home-assistant-js-websocket";
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity";
import type { HomeAssistant } from "../../types";
@@ -62,7 +62,7 @@ export const toggleGroupEntities = (
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
callService(hass.connection, domain, service, {
hass.callService(domain, service, {
entity_id: entitiesIds,
});
};

View File

@@ -2,12 +2,13 @@ import memoizeOne from "memoize-one";
import { isIPAddress } from "./is_ip_address";
const collator = memoizeOne(
(language: string | undefined) => new Intl.Collator(language)
(language: string | undefined) =>
new Intl.Collator(language, { numeric: true })
);
const caseInsensitiveCollator = memoizeOne(
(language: string | undefined) =>
new Intl.Collator(language, { sensitivity: "accent" })
new Intl.Collator(language, { sensitivity: "accent", numeric: true })
);
const fallbackStringCompare = (a: string, b: string) => {

View File

@@ -31,7 +31,8 @@ export type LocalizeKeys =
| `ui.panel.lovelace.card.${string}`
| `ui.panel.lovelace.editor.${string}`
| `ui.panel.page-authorize.form.${string}`
| `component.${string}`;
| `component.${string}`
| `ui.entity.${string}`;
export type LandingPageKeys = FlattenObjectKeys<
TranslationDict["landing-page"]

View File

@@ -390,6 +390,7 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,

View File

@@ -507,7 +507,9 @@ export class HaDataTable extends LitElement {
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
this._collapsedGroups,
this.sortColumn,
this.sortDirection
)}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
@@ -702,22 +704,37 @@ export class HaDataTable extends LitElement {
hasFab: boolean,
groupColumn: string | undefined,
groupOrder: string[] | undefined,
collapsedGroups: string[]
collapsedGroups: string[],
sortColumn: string | undefined,
sortDirection: SortingDirection
) => {
if (appendRow || hasFab || groupColumn) {
let items = [...data];
if (groupColumn) {
const isGroupSortColumn = sortColumn === groupColumn;
const grouped = groupBy(items, (item) => item[groupColumn]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sorted: Record<string, DataTableRowData[]> = Object.keys(
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
grouped
)
.sort((a, b) => {
if (!groupOrder && isGroupSortColumn) {
const comparison = stringCompare(
a,
b,
this.hass.locale.language
);
if (sortDirection === "asc") {
return comparison;
}
return comparison * -1;
}
const orderA = groupOrder?.indexOf(a) ?? -1;
const orderB = groupOrder?.indexOf(b) ?? -1;
if (orderA !== orderB) {
@@ -735,12 +752,18 @@ export class HaDataTable extends LitElement {
this.hass.locale.language
);
})
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
}, {});
.reduce(
(entries, key) => {
const entry: [string, DataTableRowData[]] = [key, grouped[key]];
entries.push(entry);
return entries;
},
[] as [string, DataTableRowData[]][]
);
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
sortedEntries.forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
@@ -836,7 +859,9 @@ export class HaDataTable extends LitElement {
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
this._collapsedGroups,
this.sortColumn,
this.sortDirection
);
if (

View File

@@ -366,6 +366,7 @@ export class HaAreaPicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}

View File

@@ -1,14 +1,15 @@
import { mdiTextureBox } from "@mdi/js";
import { mdiDrag, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { stringCompare } from "../common/string/compare";
import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
@@ -17,9 +18,14 @@ import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasDisplayValue {
hidden?: string[];
order?: string[];
export interface AreasFloorsDisplayValue {
areas_display?: {
hidden?: string[];
order?: string[];
};
floors_display?: {
order?: string[];
};
}
const UNASSIGNED_FLOOR = "__unassigned__";
@@ -30,12 +36,10 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public value?: AreasDisplayValue;
@property({ attribute: false }) public value?: AreasFloorsDisplayValue;
@property() public helper?: string;
@property({ type: Boolean }) public expanded = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -44,51 +48,79 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
public showNavigationButton = false;
protected render(): TemplateResult {
const groupedItems = this._groupedItems(this.hass.areas, this.hass.floors);
const groupedAreasItems = this._groupedAreasItems(
this.hass.areas,
this.hass.floors
);
const filteredFloors = this._sortedFloors(this.hass.floors).filter(
const filteredFloors = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).filter(
(floor) =>
// Only include floors that have areas assigned to them
groupedItems[floor.floor_id]?.length > 0
groupedAreasItems[floor.floor_id]?.length > 0
);
const value: DisplayValue = {
order: this.value?.order ?? [],
hidden: this.value?.hidden ?? [],
order: this.value?.areas_display?.order ?? [],
hidden: this.value?.areas_display?.hidden ?? [],
};
const canReorderFloors =
filteredFloors.filter((floor) => floor.floor_id !== UNASSIGNED_FLOOR)
.length > 1;
return html`
<ha-expansion-panel
outlined
.header=${this.label}
.expanded=${this.expanded}
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._floorMoved}
.disabled=${this.disabled || !canReorderFloors}
invert-swap
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
${filteredFloors.map(
(floor) => html`
<div class="floor">
<div class="header">
<ha-floor-icon .floor=${floor}></ha-floor-icon>
<p>${computeFloorName(floor)}</p>
</div>
<div class="areas">
<div>
${repeat(
filteredFloors,
(floor) => floor.floor_id,
(floor: FloorRegistryEntry) => html`
<ha-expansion-panel
outlined
.header=${computeFloorName(floor)}
left-chevron
class=${floor.floor_id === UNASSIGNED_FLOOR ? "" : "draggable"}
>
<ha-floor-icon
slot="leading-icon"
.floor=${floor}
></ha-floor-icon>
${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors
? nothing
: html`
<ha-svg-icon
class="handle"
slot="icons"
.path=${mdiDrag}
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedItems[floor.floor_id] || []}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
@value-changed=${this._areaDisplayChanged}
.showNavigationButton=${this.showNavigationButton}
></ha-items-display-editor>
</div>
</div>
`
)}
</ha-expansion-panel>
</ha-expansion-panel>
`
)}
</div>
</ha-sortable>
`;
}
private _groupedItems = memoizeOne(
private _groupedAreasItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
@@ -112,7 +144,6 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
label: area.name,
icon: area.icon ?? undefined,
iconPath: mdiTextureBox,
description: floor?.name,
});
return acc;
@@ -124,20 +155,19 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
);
private _sortedFloors = memoizeOne(
(hassFloors: HomeAssistant["floors"]): FloorRegistryEntry[] => {
const floors = Object.values(hassFloors).sort((floorA, floorB) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
(
hassFloors: HomeAssistant["floors"],
order: string[] | undefined
): FloorRegistryEntry[] => {
const floors = getFloors(hassFloors, order);
const noFloors = floors.length === 0;
floors.push({
floor_id: UNASSIGNED_FLOOR,
name: this.hass.localize(
"ui.panel.lovelace.strategy.areas.unassigned_areas"
),
name: noFloors
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null,
level: 999999,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
@@ -146,68 +176,101 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
}
);
private async _areaDisplayChanged(ev) {
private _floorMoved(ev: CustomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation();
const value = ev.detail.value as DisplayValue;
const currentFloorId = ev.currentTarget.floorId;
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedFloorId);
const newValue: AreasFloorsDisplayValue = {
areas_display: this.value?.areas_display,
floors_display: {
order: newOrder,
},
};
if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
const floorIds = this._sortedFloors(this.hass.floors).map(
(floor) => floor.floor_id
);
private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) {
ev.stopPropagation();
const value = ev.detail.value;
const currentFloorId = (ev.currentTarget as any).floorId;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
const oldHidden = oldAreaDisplay?.hidden ?? [];
const oldOrder = oldAreaDisplay?.order ?? [];
const newHidden: string[] = [];
const newOrder: string[] = [];
for (const floorId of floorIds) {
if (currentFloorId === floorId) {
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
newHidden.push(...(value.hidden ?? []));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = this.value?.hidden?.filter(
(areaId) => this.hass.areas[areaId]?.floor_id === floorId
);
if (hidden) {
const hidden = oldHidden.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
const order = this.value?.order?.filter(
(areaId) => this.hass.areas[areaId]?.floor_id === floorId
);
if (order) {
const order = oldOrder.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (order?.length) {
newOrder.push(...order);
}
}
const newValue: AreasDisplayValue = {
hidden: newHidden,
order: newOrder,
const newValue: AreasFloorsDisplayValue = {
areas_display: {
hidden: newHidden,
order: newOrder,
},
floors_display: this.value?.floors_display,
};
if (newValue.hidden?.length === 0) {
delete newValue.hidden;
if (newValue.areas_display?.hidden?.length === 0) {
delete newValue.areas_display.hidden;
}
if (newValue.order?.length === 0) {
delete newValue.order;
if (newValue.areas_display?.order?.length === 0) {
delete newValue.areas_display.order;
}
if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
static styles = css`
.floor .header p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex: 1:
ha-expansion-panel {
margin-bottom: 8px;
--expansion-panel-summary-padding: 0 16px;
}
.floor .header {
margin: 16px 0 8px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
ha-expansion-panel [slot="leading-icon"] {
margin-inline-end: 16px;
}
label {
display: block;
font-weight: var(--ha-font-weight-bold);
margin-bottom: 8px;
}
`;
}

View File

@@ -61,8 +61,8 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean }) public error = false;
@property({ type: Boolean, attribute: "enable-fullscreen" })
public enableFullscreen = true;
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@state() private _value = "";
@@ -179,7 +179,7 @@ export class HaCodeEditor extends ReactiveElement {
if (changedProps.has("_isFullscreen")) {
this.classList.toggle("fullscreen", this._isFullscreen);
}
if (changedProps.has("enableFullscreen")) {
if (changedProps.has("disableFullscreen")) {
this._updateFullscreenButton();
}
}
@@ -263,7 +263,7 @@ export class HaCodeEditor extends ReactiveElement {
private _updateFullscreenButton() {
const existingButton = this.renderRoot.querySelector(".fullscreen-button");
if (!this.enableFullscreen) {
if (this.disableFullscreen) {
// Remove button if it exists and fullscreen is disabled
if (existingButton) {
existingButton.remove();
@@ -317,7 +317,7 @@ export class HaCodeEditor extends ReactiveElement {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
} else if (e.key === "F11" && this.enableFullscreen) {
} else if (e.key === "F11" && !this.disableFullscreen) {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
@@ -557,11 +557,11 @@ export class HaCodeEditor extends ReactiveElement {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
z-index: 1;
color: var(--secondary-text-color);
background-color: var(--card-background-color);
background-color: var(--secondary-background-color);
border-radius: 50%;
opacity: 0.6;
opacity: 0.9;
transition: opacity 0.2s;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 18px;
@@ -584,22 +584,25 @@ export class HaCodeEditor extends ReactiveElement {
:host(.fullscreen) {
position: fixed !important;
top: var(--header-height, 56px) !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: calc(var(--header-height, 56px) + 8px) !important;
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 9999 !important;
background-color: var(--primary-background-color) !important;
border-radius: 12px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
background-color: var(
--code-editor-background-color,
var(--card-background-color)
) !important;
margin: 0 !important;
padding: 16px !important;
/* Respect iOS safe areas while accounting for header */
padding-top: max(16px, env(safe-area-inset-top)) !important;
padding-left: max(16px, env(safe-area-inset-left)) !important;
padding-right: max(16px, env(safe-area-inset-right)) !important;
padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
padding-top: var(--safe-area-inset-top) !important;
padding-left: var(--safe-area-inset-left) !important;
padding-right: var(--safe-area-inset-right) !important;
padding-bottom: var(--safe-area-inset-bottom) !important;
box-sizing: border-box !important;
display: flex !important;
flex-direction: column !important;
display: block !important;
}
:host(.fullscreen) .cm-editor {
@@ -609,12 +612,8 @@ export class HaCodeEditor extends ReactiveElement {
}
:host(.fullscreen) .fullscreen-button {
position: fixed;
top: calc(
var(--header-height, 56px) + max(8px, env(safe-area-inset-top))
);
right: max(24px, calc(env(safe-area-inset-right) + 8px));
z-index: 10000;
top: calc(var(--safe-area-inset-top, 0px) + 8px);
right: calc(var(--safe-area-inset-right, 0px) + 8px);
}
`;
}

View File

@@ -369,7 +369,6 @@ export class HaComboBox extends LitElement {
}
vaadin-combo-box-light {
position: relative;
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
}
ha-combo-box-textfield {
width: 100%;

View File

@@ -26,7 +26,6 @@ export class HaControlButtonGroup extends LitElement {
.container {
display: flex;
flex-direction: row;
justify-content: var(--control-button-group-alignment, start);
width: 100%;
height: 100%;
}

View File

@@ -72,6 +72,9 @@ export class HaControlButton extends LitElement {
color 180ms ease-in-out;
color: var(--control-button-icon-color);
}
:host([vertical]) .button {
flex-direction: column;
}
.button:focus-visible {
box-shadow: 0 0 0 2px var(--control-button-focus-color);
}

View File

@@ -14,6 +14,7 @@ import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@@ -34,10 +35,11 @@ export class HaFilterBlueprints extends LitElement {
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
if (this.value?.length) {
this._findRelated();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -130,17 +132,15 @@ export class HaFilterBlueprints extends LitElement {
}
this.value = value;
this._findRelated();
}
private async _findRelated() {
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}

View File

@@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { stringCompare } from "../common/string/compare";
import { deepEqual } from "../common/util/deep-equal";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
@@ -37,9 +38,13 @@ export class HaFilterDevices extends LitElement {
if (!this.hasUpdated) {
loadVirtualizer();
if (this.value?.length) {
this._findRelated();
}
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -110,7 +115,6 @@ export class HaFilterDevices extends LitElement {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
protected updated(changed) {
@@ -160,11 +164,11 @@ export class HaFilterDevices extends LitElement {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
@@ -176,7 +180,6 @@ export class HaFilterDevices extends LitElement {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items = new Set<string>();
for (const result of results) {

View File

@@ -17,6 +17,7 @@ import "./ha-expansion-panel";
import "./ha-list";
import "./ha-state-icon";
import "./search-input-outlined";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@@ -39,9 +40,13 @@ export class HaFilterEntities extends LitElement {
if (!this.hasUpdated) {
loadVirtualizer();
if (this.value?.length) {
this._findRelated();
}
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -131,7 +136,6 @@ export class HaFilterEntities extends LitElement {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
private _expandedWillChange(ev) {
@@ -178,11 +182,11 @@ export class HaFilterEntities extends LitElement {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}

View File

@@ -20,6 +20,7 @@ import "./ha-icon-button";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import { deepEqual } from "../common/util/deep-equal";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -41,10 +42,11 @@ export class HaFilterFloorAreas extends LitElement {
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
if (this.value?.floors?.length || this.value?.areas?.length) {
this._findRelated();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
) {
this._findRelated();
}
}
@@ -174,8 +176,6 @@ export class HaFilterFloorAreas extends LitElement {
}
listItem.selected = this.value[type]?.includes(value);
this._findRelated();
}
protected updated(changed) {
@@ -188,10 +188,6 @@ export class HaFilterFloorAreas extends LitElement {
}
}
protected firstUpdated() {
this._findRelated();
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
@@ -226,6 +222,7 @@ export class HaFilterFloorAreas extends LitElement {
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
value: {},
items: undefined,

View File

@@ -433,6 +433,7 @@ export class HaFloorPicker extends LitElement {
}
},
});
return;
}
this._setValue(value);

View File

@@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement {
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
${!showIcon
? nothing
: icon
@@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement {
<span slot="end"> ${this.actionsRenderer(item)} </span>
`
: nothing}
${this.showNavigationButton
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
@@ -174,9 +161,22 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value}
@click=${this._toggle}
></ha-icon-button>
${this.showNavigationButton
? html` <ha-icon-next slot="end"></ha-icon-next> `
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="end"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="end"></ha-svg-icon>`}
</ha-md-list-item>
`;
}

View File

@@ -24,6 +24,10 @@ const MANUAL_SCHEMA = [
{ name: "media_content_type", required: false, selector: { text: {} } },
] as const;
const INCLUDE_DOMAINS = ["media_player"];
const EMPTY_FORM = {};
@customElement("ha-selector-media")
export class HaMediaSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -84,7 +88,7 @@ export class HaMediaSelector extends LitElement {
(stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
const hasAccept = this.selector.media?.accept?.length;
const hasAccept = this.selector?.media?.accept?.length;
return html`
${hasAccept
@@ -100,7 +104,7 @@ export class HaMediaSelector extends LitElement {
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
include-domains='["media_player"]'
.includeDomains=${INCLUDE_DOMAINS}
allow-custom-entity
@value-changed=${this._entityChanged}
></ha-entity-picker>
@@ -114,7 +118,7 @@ export class HaMediaSelector extends LitElement {
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value}
.data=${this.value || EMPTY_FORM}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
></ha-form>
@@ -122,63 +126,62 @@ export class HaMediaSelector extends LitElement {
: html`
<ha-card
outlined
tabindex="0"
role="button"
aria-label=${!this.value?.media_content_id
? this.hass.localize("ui.components.selectors.media.pick_media")
: this.value.metadata?.title || this.value.media_content_id}
@click=${this._pickMedia}
@keydown=${this._handleKeyDown}
class=${this.disabled || (!this.value?.entity_id && !hasAccept)
? "disabled"
: ""}
>
<div
class="thumbnail ${classMap({
portrait:
!!this.value?.metadata?.media_class &&
MediaClassBrowserSettings[
this.value.metadata.children_media_class ||
this.value.metadata.media_class
].thumbnail_ratio === "portrait",
})}"
>
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id}
<div class="content-container">
<div class="thumbnail">
${this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox}
></ha-svg-icon>
</div>
`}
</div>
<div class="title">
${!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id}
</div>
</div>
</ha-card>
`}
@@ -229,6 +232,13 @@ export class HaMediaSelector extends LitElement {
});
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._pickMedia();
}
}
static styles = css`
ha-entity-picker {
display: block;
@@ -243,41 +253,52 @@ export class HaMediaSelector extends LitElement {
}
ha-card {
position: relative;
width: 200px;
width: 100%;
box-sizing: border-box;
cursor: pointer;
transition: background-color 180ms ease-in-out;
min-height: 56px;
}
ha-card:hover:not(.disabled),
ha-card:focus:not(.disabled) {
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
}
ha-card:focus {
outline: none;
}
ha-card.disabled {
pointer-events: none;
color: var(--disabled-text-color);
}
.content-container {
display: flex;
align-items: center;
padding: 8px;
gap: 12px;
}
ha-card .thumbnail {
width: 100%;
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
transition: padding-bottom 0.1s ease-out;
padding-bottom: 100%;
}
ha-card .thumbnail.portrait {
padding-bottom: 150%;
border-radius: 8px;
overflow: hidden;
}
ha-card .image {
border-radius: 3px 3px 0 0;
border-radius: 8px;
}
.folder {
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
--mdc-icon-size: 24px;
}
.title {
font-size: var(--ha-font-size-l);
padding-top: 16px;
font-size: var(--ha-font-size-m);
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 16px;
padding-left: 16px;
padding-right: 4px;
padding-inline-start: 16px;
padding-inline-end: 4px;
white-space: nowrap;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.image {
position: absolute;
@@ -290,13 +311,15 @@ export class HaMediaSelector extends LitElement {
background-position: center;
}
.centered-image {
margin: 0 8px;
margin: 4px;
background-size: contain;
}
.icon-holder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
`;
}

View File

@@ -23,6 +23,9 @@ export class HaNumberSelector extends LitElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public required = true;
@property({ type: Boolean }) public disabled = false;
@@ -60,6 +63,14 @@ export class HaNumberSelector extends LitElement {
}
}
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
if (isBox && unit && this.localizeValue && translationKey) {
unit =
this.localizeValue(`${translationKey}.unit_of_measurement.${unit}`) ||
unit;
}
return html`
${this.label && !isBox
? html`${this.label}${this.required ? "*" : ""}`
@@ -97,7 +108,7 @@ export class HaNumberSelector extends LitElement {
.helper=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.suffix=${this.selector.number?.unit_of_measurement}
.suffix=${unit}
type="number"
autoValidate
?no-spinner=${!isBox}

View File

@@ -122,11 +122,7 @@ export class HaObjectSelector extends LitElement {
}
protected render() {
if (!this.selector.object) {
return nothing;
}
if (this.selector.object.fields) {
if (this.selector.object?.fields) {
if (this.selector.object.multiple) {
const items = ensureArray(this.value ?? []);
return html`

View File

@@ -89,6 +89,7 @@ export class HaSettingsRow extends LitElement {
display: var(--settings-row-content-display, flex);
justify-content: flex-end;
flex: 1;
min-width: 0;
padding: 16px 0;
}
.content ::slotted(*) {

View File

@@ -44,6 +44,9 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: "read-only", type: Boolean }) public readOnly = false;
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@property({ type: Boolean }) public required = false;
@property({ attribute: "copy-clipboard", type: Boolean })
@@ -110,6 +113,7 @@ export class HaYamlEditor extends LitElement {
.hass=${this.hass}
.value=${this._yaml}
.readOnly=${this.readOnly}
.disableFullscreen=${this.disableFullscreen}
mode="yaml"
autocomplete-entities
autocomplete-icons

View File

@@ -36,6 +36,8 @@ declare global {
}
}
const PROGRAMMITIC_FIT_DELAY = 250;
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
@@ -113,14 +115,33 @@ export class HaMap extends ReactiveElement {
private _clickCount = 0;
private _isProgrammaticFit = false;
private _pauseAutoFit = false;
public connectedCallback(): void {
this._pauseAutoFit = false;
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._handleVisibilityChange();
super.connectedCallback();
this._loadMap();
this._attachObserver();
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
this._pauseAutoFit = false;
}, 500);
}
};
public disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
if (this.leafletMap) {
this.leafletMap.remove();
this.leafletMap = undefined;
@@ -145,7 +166,7 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
autoFitRequired = true;
autoFitRequired = !this._pauseAutoFit;
} else if (this._loaded && oldHass && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
@@ -154,7 +175,7 @@ export class HaMap extends ReactiveElement {
this.hass!.states[getEntityId(entity)]
) {
this._drawEntities();
autoFitRequired = true;
autoFitRequired = !this._pauseAutoFit;
break;
}
}
@@ -178,7 +199,11 @@ export class HaMap extends ReactiveElement {
}
if (changedProps.has("zoom")) {
this._isProgrammaticFit = true;
this.leafletMap!.setZoom(this.zoom);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
if (
@@ -234,13 +259,30 @@ export class HaMap extends ReactiveElement {
}
this._clickCount++;
});
this.leafletMap.on("zoomstart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this.leafletMap.on("movestart", () => {
if (!this._isProgrammaticFit) {
this._pauseAutoFit = true;
}
});
this._loaded = true;
} finally {
this._loading = false;
}
}
public fitMap(options?: { zoom?: number; pad?: number }): void {
public fitMap(options?: {
zoom?: number;
pad?: number;
unpause_autofit?: boolean;
}): void {
if (options?.unpause_autofit) {
this._pauseAutoFit = false;
}
if (!this.leafletMap || !this.Leaflet || !this.hass) {
return;
}
@@ -250,6 +292,7 @@ export class HaMap extends ReactiveElement {
!this._mapFocusZones.length &&
!this.layers?.length
) {
this._isProgrammaticFit = true;
this.leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
@@ -257,6 +300,9 @@ export class HaMap extends ReactiveElement {
),
options?.zoom || this.zoom
);
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
return;
}
@@ -277,8 +323,11 @@ export class HaMap extends ReactiveElement {
});
bounds = bounds.pad(options?.pad ?? 0.5);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
public fitBounds(
@@ -291,7 +340,11 @@ export class HaMap extends ReactiveElement {
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
options?.pad ?? 0.5
);
this._isProgrammaticFit = true;
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
setTimeout(() => {
this._isProgrammaticFit = false;
}, PROGRAMMITIC_FIT_DELAY);
}
private _drawLayers(prevLayers: Layer[] | undefined): void {

View File

@@ -1,14 +1,23 @@
import type { HomeAssistant } from "../types";
import type { Selector } from "./selector";
export interface AITaskPreferences {
gen_data_entity_id: string | null;
}
export interface GenDataTaskResult {
export interface GenDataTaskResult<T = string> {
conversation_id: string;
data: string;
data: T;
}
export interface AITaskStructureField {
description?: string;
required?: boolean;
selector: Selector;
}
export type AITaskStructure = Record<string, AITaskStructureField>;
export const fetchAITaskPreferences = (hass: HomeAssistant) =>
hass.callWS<AITaskPreferences>({
type: "ai_task/preferences/get",
@@ -23,15 +32,16 @@ export const saveAITaskPreferences = (
...preferences,
});
export const generateDataAITask = async (
export const generateDataAITask = async <T = string>(
hass: HomeAssistant,
task: {
task_name: string;
entity_id?: string;
instructions: string;
structure?: AITaskStructure;
}
): Promise<GenDataTaskResult> => {
const result = await hass.callService<GenDataTaskResult>(
): Promise<GenDataTaskResult<T>> => {
const result = await hass.callService<GenDataTaskResult<T>>(
"ai_task",
"generate_data",
task,

View File

@@ -1109,21 +1109,31 @@ export const computeConsumptionSingle = (data: {
export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string
unit: string,
targetUnit?: string
): string => {
if (!consumption) {
return `0 ${unit}`;
}
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
let val = consumption;
let val = consumption || 0;
let targetUnitIndex = -1;
if (targetUnit) {
targetUnitIndex = units.findIndex((u) => u === targetUnit);
}
let unitIndex = units.findIndex((u) => u === unit);
if (unitIndex >= 0) {
while (Math.abs(val) < 1 && unitIndex > 0) {
while (
targetUnitIndex > -1
? targetUnitIndex < unitIndex
: Math.abs(val) < 1 && unitIndex > 0
) {
val *= 1000;
unitIndex--;
}
while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) {
while (
targetUnitIndex > -1
? targetUnitIndex > unitIndex
: Math.abs(val) >= 1000 && unitIndex < units.length - 1
) {
val /= 1000;
unitIndex++;
}

View File

@@ -354,7 +354,10 @@ const getIconFromTranslations = (
}
// Then check for range-based icons if we have a numeric state
if (state !== undefined && translations.range && !isNaN(Number(state))) {
return getIconFromRange(Number(state), translations.range);
return (
getIconFromRange(Number(state), translations.range) ??
translations.default
);
}
// Fallback to default icon
return translations.default;
@@ -518,7 +521,10 @@ export const domainIcon = async (
}
// Then check for range-based icons if we have a numeric state
if (state !== undefined && translations.range && !isNaN(Number(state))) {
return getIconFromRange(Number(state), translations.range);
return (
getIconFromRange(Number(state), translations.range) ??
translations.default
);
}
// Fallback to default icon
return translations.default;

View File

@@ -333,6 +333,7 @@ export interface NumberSelector {
mode?: "box" | "slider";
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
} | null;
}

View File

@@ -286,7 +286,7 @@ class DataEntryFlowDialog extends LitElement {
scrimClickAction
escapeKeyAction
hideActions
.heading=${dialogTitle}
.heading=${dialogTitle || true}
>
<ha-dialog-header slot="heading">
<ha-icon-button
@@ -438,7 +438,10 @@ class DataEntryFlowDialog extends LitElement {
return;
}
this._loading = "loading_step";
const delayedLoading = setTimeout(() => {
// only show loading for slow steps to avoid flickering
this._loading = "loading_step";
}, 250);
let _step: DataEntryFlowStep;
try {
_step = await step;
@@ -452,6 +455,7 @@ class DataEntryFlowDialog extends LitElement {
});
return;
} finally {
clearTimeout(delayedLoading);
this._loading = undefined;
}

View File

@@ -35,10 +35,16 @@ export const showConfigFlowDialog = (
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation("config", step.handler);
await hass.loadBackendTranslation("selector", step.handler);
const [step] = await Promise.all([
fetchConfigFlow(hass, flowId),
hass.loadFragmentTranslation("config"),
]);
await Promise.all([
hass.loadBackendTranslation("config", step.handler),
hass.loadBackendTranslation("selector", step.handler),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", step.handler),
]);
return step;
},
handleFlowStep: handleConfigFlowStep,

View File

@@ -0,0 +1,272 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeGroupEntitiesState } from "../../../../common/entity/group_entities";
import "../../../../components/ha-control-button";
import "../../../../components/ha-control-button-group";
import "../../../../components/ha-domain-icon";
import { isFullyClosed, isFullyOpen } from "../../../../data/cover";
import { OFF, ON, UNAVAILABLE } from "../../../../data/entity";
import { forwardHaptic } from "../../../../data/haptics";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { TileCardConfig } from "../../../../panels/lovelace/cards/types";
import "../../../../panels/lovelace/sections/hui-section";
import type { HomeAssistant } from "../../../../types";
import "../ha-more-info-state-header";
export interface GroupToggleDialogParams {
entityIds: string[];
}
@customElement("ha-more-info-view-toggle-group")
class HaMoreInfoViewToggleGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params?: GroupToggleDialogParams;
private _sectionConfig = memoizeOne(
(entities: string[]): LovelaceSectionConfig => ({
type: "grid",
cards: entities.map<TileCardConfig>((entity) => ({
type: "tile",
entity: entity,
icon_tap_action: {
action: "toggle",
},
tap_action: {
action: "more-info",
},
grid_options: {
columns: 12,
},
})),
})
);
private _combineEntities(entities: HassEntity[]): HassEntity {
const firstEntity = entities[0];
const domain = computeStateDomain(firstEntity);
const combined: HassEntity = {
entity_id: `${domain}.all_entities`,
state: computeGroupEntitiesState(entities),
attributes: {
device_class: firstEntity.attributes.device_class,
},
last_changed: new Date(
Math.max(...entities.map((e) => new Date(e.last_changed).getTime()))
).toISOString(),
last_updated: new Date(
Math.max(...entities.map((e) => new Date(e.last_updated).getTime()))
).toISOString(),
context: {
id: "",
parent_id: "",
user_id: "",
},
};
return combined;
}
protected render() {
if (!this.params) {
return nothing;
}
const sectionConfig = this._sectionConfig(this.params.entityIds);
const entities = this.params.entityIds
.map((entityId) => this.hass!.states[entityId] as HassEntity | undefined)
.filter((v): v is HassEntity => Boolean(v));
const groupStateObj = this._combineEntities(entities);
const formattedGroupState = this.hass.formatEntityState(groupStateObj);
const domain = computeStateDomain(groupStateObj);
const deviceClass = groupStateObj.attributes.device_class;
const availableEntities = entities.filter(
(entity) => entity.state !== UNAVAILABLE
);
const ON_STATE = domain === "cover" ? "open" : ON;
const OFF_STATE = domain === "cover" ? "closed" : OFF;
const isAllOn = availableEntities.every((entity) =>
computeDomain(entity.entity_id) === "cover"
? isFullyOpen(entity)
: entity.state === ON_STATE
);
const isAllOff = availableEntities.every((entity) =>
computeDomain(entity.entity_id) === "cover"
? isFullyClosed(entity)
: entity.state === OFF_STATE
);
const isMultiple = this.params.entityIds.length > 1;
return html`
<div class="content">
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${groupStateObj}
.stateOverride=${formattedGroupState}
></ha-more-info-state-header>
<div class="main">
<ha-control-button-group vertical>
<ha-control-button
vertical
@click=${this._turnAllOn}
.disabled=${isAllOn}
>
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
.state=${ON_STATE}
.deviceClass=${deviceClass}
></ha-domain-icon>
<p>
${domain === "cover"
? isMultiple
? this.hass.localize("ui.card.cover.open_all")
: this.hass.localize("ui.card.cover.open")
: isMultiple
? this.hass.localize("ui.card.common.turn_on_all")
: this.hass.localize("ui.card.common.turn_on")}
</p>
</ha-control-button>
<ha-control-button
vertical
@click=${this._turnAllOff}
.disabled=${isAllOff}
>
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
.state=${OFF_STATE}
.deviceClass=${deviceClass}
.icon=${domain === "light" ? "mdi:lightbulb-off" : undefined}
></ha-domain-icon>
<p>
${domain === "cover"
? isMultiple
? this.hass.localize("ui.card.cover.close_all")
: this.hass.localize("ui.card.cover.close")
: isMultiple
? this.hass.localize("ui.card.common.turn_off_all")
: this.hass.localize("ui.card.common.turn_off")}
</p>
</ha-control-button>
</ha-control-button-group>
</div>
<div class="entities">
<hui-section
.config=${sectionConfig}
.hass=${this.hass}
></hui-section>
</div>
</div>
`;
}
private _turnAllOff() {
if (!this.params) {
return;
}
forwardHaptic("light");
const domain = computeDomain(this.params.entityIds[0]);
if (domain === "cover") {
this.hass.callService("cover", "close_cover", {
entity_id: this.params.entityIds,
});
return;
}
this.hass.callService("homeassistant", "turn_off", {
entity_id: this.params.entityIds,
});
}
private _turnAllOn() {
if (!this.params) {
return;
}
forwardHaptic("light");
const domain = computeDomain(this.params.entityIds[0]);
if (domain === "cover") {
this.hass.callService("cover", "open_cover", {
entity_id: this.params.entityIds,
});
return;
}
this.hass.callService("homeassistant", "turn_on", {
entity_id: this.params.entityIds,
});
}
static styles = [
css`
:host {
display: flex;
flex: 1;
flex-direction: column;
}
.content {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 24px;
}
ha-more-info-state-header {
margin-top: 24px;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
ha-control-button-group {
--control-button-group-spacing: 12px;
--control-button-group-thickness: 130px;
}
ha-control-button {
--control-button-border-radius: 16px;
--mdc-icon-size: 24px;
--control-button-padding: 16px 8px;
--control-button-background-opacity: 0.1;
}
ha-control-button p {
margin: 0;
}
.entities {
box-sizing: border-box;
width: 100%;
background-color: var(--primary-background-color);
padding: 12px;
padding-bottom: max(var(--safe-area-inset-bottom), 12px);
}
hui-section {
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-toggle-group": HaMoreInfoViewToggleGroup;
}
}

View File

@@ -8,9 +8,9 @@ export const showVoiceAssistantsView = (
title: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-voice-assistants",
viewImport: loadVoiceAssistantsView,
viewTitle: title,
viewParams: {},
tag: "ha-more-info-view-voice-assistants",
import: loadVoiceAssistantsView,
title: title,
params: {},
});
};

View File

@@ -10,7 +10,7 @@ import {
mdiPencilOutline,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
@@ -68,15 +68,24 @@ export interface MoreInfoDialogParams {
view?: View;
/** @deprecated Use `view` instead */
tab?: View;
parentView?: ParentView;
}
type View = "info" | "history" | "settings" | "related";
type View = "info" | "history" | "settings" | "related" | "parent";
interface ParentView {
tag: string;
title?: string;
subtitle?: string;
import?: () => Promise<unknown>;
params?: any;
}
interface ChildView {
viewTag: string;
viewTitle?: string;
viewImport?: () => Promise<unknown>;
viewParams?: any;
tag: string;
title?: string;
import?: () => Promise<unknown>;
params?: any;
}
declare global {
@@ -88,7 +97,8 @@ declare global {
}
}
const DEFAULT_VIEW: View = "info";
const INFO_VIEW: View = "info";
const PARENT_VIEW: View = "parent";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends LitElement {
@@ -98,9 +108,11 @@ export class MoreInfoDialog extends LitElement {
@state() private _entityId?: string | null;
@state() private _currView: View = DEFAULT_VIEW;
@state() private _currView: View = INFO_VIEW;
@state() private _initialView: View = DEFAULT_VIEW;
@state() private _initialView: View = INFO_VIEW;
@state() private _parentView?: ParentView;
@state() private _childView?: ChildView;
@@ -114,17 +126,29 @@ export class MoreInfoDialog extends LitElement {
public showDialog(params: MoreInfoDialogParams) {
this._entityId = params.entityId;
if (!this._entityId) {
if (!this._entityId && !params.parentView) {
this.closeDialog();
return;
}
this._currView = params.view || DEFAULT_VIEW;
this._initialView = params.view || DEFAULT_VIEW;
this._parentView = params.parentView;
if (this._parentView?.import) {
this._parentView.import();
this._currView = PARENT_VIEW;
} else {
this._currView = params.view || INFO_VIEW;
}
this._initialView = params.view || INFO_VIEW;
this._childView = undefined;
this.large = false;
this._loadEntityRegistryEntry();
}
public willUpdate(changedProps: PropertyValues): void {
if (changedProps.has("_entityId")) {
this._loadEntityRegistryEntry();
}
}
private async _loadEntityRegistryEntry() {
if (!this._entityId) {
return;
@@ -143,19 +167,18 @@ export class MoreInfoDialog extends LitElement {
this._entityId = undefined;
this._entry = undefined;
this._childView = undefined;
this._parentView = undefined;
this._currView = INFO_VIEW;
this._infoEditMode = false;
this._initialView = DEFAULT_VIEW;
this._initialView = INFO_VIEW;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _shouldShowEditIcon(
domain: string,
stateObj: HassEntity | undefined
): boolean {
if (__DEMO__ || !stateObj) {
private _shouldShowEditIcon(domain?: string, stateObj?: HassEntity): boolean {
if (__DEMO__ || !stateObj || !domain) {
return false;
}
if (EDITABLE_DOMAINS_WITH_ID.includes(domain) && stateObj.attributes.id) {
@@ -171,8 +194,9 @@ export class MoreInfoDialog extends LitElement {
return false;
}
private _shouldShowHistory(domain: string): boolean {
private _shouldShowHistory(domain?: string): boolean {
return (
domain !== undefined &&
DOMAINS_WITH_MORE_INFO.includes(domain) &&
(computeShowHistoryComponent(this.hass, this._entityId!) ||
computeShowLogBookComponent(
@@ -207,14 +231,30 @@ export class MoreInfoDialog extends LitElement {
private _goBack() {
if (this._childView) {
this._childView = undefined;
} else {
this._setView(this._initialView);
return;
}
const previousView = this._previousView();
if (previousView) {
this._setView(previousView);
}
}
private _previousView(): View | undefined {
if (this._currView === PARENT_VIEW) {
return undefined;
}
if (this._currView !== this._initialView) {
return this._initialView;
}
if (this._parentView) {
return PARENT_VIEW;
}
return undefined;
}
private _resetInitialView() {
this._initialView = DEFAULT_VIEW;
this._setView(DEFAULT_VIEW);
this._initialView = INFO_VIEW;
this._setView(INFO_VIEW);
}
private _goToHistory() {
@@ -227,8 +267,8 @@ export class MoreInfoDialog extends LitElement {
private _showChildView(ev: CustomEvent): void {
const view = ev.detail as ChildView;
if (view.viewImport) {
view.viewImport();
if (view.import) {
view.import();
}
this._childView = view;
}
@@ -286,45 +326,99 @@ export class MoreInfoDialog extends LitElement {
this._sensorNumericDeviceClasses = deviceClasses.numeric_device_classes;
}
private _handleMoreInfoEvent(ev: CustomEvent) {
// If the parent view has a `show-dialog` event to open more info, we handle it here to set the entity ID and view.
const detail = ev.detail as MoreInfoDialogParams;
if (detail.entityId) {
this._entityId = detail.entityId;
this._setView(detail.view || INFO_VIEW);
ev.stopPropagation();
ev.preventDefault();
}
}
private _renderHeader = (): TemplateResult | typeof nothing => {
if (this._parentView && this._currView === PARENT_VIEW) {
return html`
${this._parentView
? html`<p class="breadcrumb">${this._parentView.subtitle}</p>`
: nothing}
<p class="main">${this._parentView.title}</p>
`;
}
const entityId = this._entityId;
if (entityId) {
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const context = stateObj
? getEntityContext(stateObj, this.hass)
: this._entry
? getEntityEntryContext(this._entry, this.hass)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass)
: this._entry
? computeEntityEntryName(this._entry, this.hass)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area
? computeAreaName(context.area)
: undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.title || breadcrumb.pop() || entityId;
const isAdmin = this.hass.user!.is_admin;
return html`
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
`;
}
return nothing;
};
protected render() {
if (!this._entityId) {
if (!this._entityId && !this._parentView) {
return nothing;
}
const entityId = this._entityId;
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const domain = computeDomain(entityId);
const domain = entityId ? computeDomain(entityId) : undefined;
const isAdmin = this.hass.user!.is_admin;
const deviceId = this._getDeviceId();
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
const previousView = this._previousView();
const isDefaultView = this._currView === INFO_VIEW && !this._childView;
const isSpecificInitialView =
this._initialView !== DEFAULT_VIEW && !this._childView;
const showCloseIcon = isDefaultView || isSpecificInitialView;
const context = stateObj
? getEntityContext(stateObj, this.hass)
: this._entry
? getEntityEntryContext(this._entry, this.hass)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass)
: this._entry
? computeEntityEntryName(this._entry, this.hass)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
this._initialView !== INFO_VIEW && !this._childView;
const showCloseIcon = !previousView && !this._childView;
return html`
<ha-dialog
@@ -332,7 +426,7 @@ export class MoreInfoDialog extends LitElement {
@closed=${this.closeDialog}
@opened=${this._handleOpened}
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
.heading=${title}
.heading=${" "}
hideActions
flexContent
>
@@ -356,24 +450,7 @@ export class MoreInfoDialog extends LitElement {
></ha-icon-button-prev>
`}
<span slot="title" @click=${this._enlarge} class="title">
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
${this._renderHeader()}
</span>
${isDefaultView
? html`
@@ -521,54 +598,62 @@ export class MoreInfoDialog extends LitElement {
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${cache(
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
${dynamicElement(this._childView.tag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
params: this._childView.params,
})}
</div>
`
: this._currView === "info"
? html`
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
`
: this._currView === "history"
: this._currView === "parent"
? dynamicElement(this._parentView!.tag, {
hass: this.hass,
entry: this._entry,
params: this._parentView!.params,
})
: this._currView === "info"
? html`
<ha-more-info-history-and-logbook
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
.entry=${this._entry}
.editMode=${this._infoEditMode}
></ha-more-info-info>
`
: this._currView === "settings"
: this._currView === "history"
? html`
<ha-more-info-settings
<ha-more-info-history-and-logbook
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
></ha-more-info-history-and-logbook>
`
: this._currView === "related"
: this._currView === "settings"
? html`
<ha-related-items
<ha-more-info-settings
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
`
: nothing
: this._currView === "related"
? html`
<ha-related-items
.hass=${this.hass}
.itemId=${entityId}
.itemType=${domain &&
SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: nothing
)}
</div>
</ha-dialog>

View File

@@ -208,6 +208,7 @@ class DialogEditSidebar extends LitElement {
ha-md-dialog {
min-width: 600px;
max-height: 90%;
--dialog-content-padding: 8px 24px;
}
@media all and (max-width: 600px), all and (max-height: 500px) {

View File

@@ -2,12 +2,19 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-selector/ha-selector-media";
import "../../../../../components/ha-selector/ha-selector";
import type { PlayMediaAction } from "../../../../../data/script";
import type { MediaSelectorValue } from "../../../../../data/selector";
import type {
MediaSelectorValue,
Selector,
} from "../../../../../data/selector";
import type { HomeAssistant } from "../../../../../types";
import type { ActionElement } from "../ha-automation-action-row";
const MEDIA_SELECTOR_SCHEMA: Selector = {
media: {},
};
@customElement("ha-automation-action-play_media")
export class HaPlayMediaAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,12 +45,13 @@ export class HaPlayMediaAction extends LitElement implements ActionElement {
protected render() {
return html`
<ha-selector-media
<ha-selector
.selector=${MEDIA_SELECTOR_SCHEMA}
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._getSelectorValue(this.action)}
@value-changed=${this._valueChanged}
></ha-selector-media>
></ha-selector>
`;
}

View File

@@ -30,6 +30,9 @@ import {
generateDataAITask,
} from "../../../../data/ai_task";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -75,7 +78,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._entryUpdates.category ? "category" : "",
this._entryUpdates.labels.length > 0 ? "labels" : "",
this._entryUpdates.area ? "area" : "",
];
].filter(Boolean);
}
public closeDialog() {
@@ -346,17 +349,121 @@ class DialogAutomationSave extends LitElement implements HassDialog {
}
private async _suggest() {
const result = await generateDataAITask(this.hass, {
task_name: "frontend:automation:save",
instructions: `Suggest one name for the following Home Assistant automation.
Your answer should only contain the name, without any additional text or formatting.
The name should be relevant to the automation's purpose and should not exceed 50 characters.
The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.
const labels = await subscribeOne(
this.hass.connection,
subscribeLabelRegistry
).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
);
const automationInspiration: string[] = [];
for (const automation of Object.values(this.hass.states)) {
const entityEntry = this.hass.entities[automation.entity_id];
if (
computeStateDomain(automation) !== "automation" ||
automation.attributes.restored ||
!automation.attributes.friendly_name ||
!entityEntry
) {
continue;
}
let inspiration = `- ${automation.attributes.friendly_name}`;
if (entityEntry.labels.length) {
inspiration += ` (labels: ${entityEntry.labels
.map((label) => labels[label])
.join(", ")})`;
}
automationInspiration.push(inspiration);
}
const result = await generateDataAITask<{
name: string;
description: string | undefined;
labels: string[] | undefined;
}>(this.hass, {
task_name: "frontend:automation:save",
instructions: `Suggest in language "${this.hass.language}" a name, description, and labels for the following Home Assistant automation.
The name should be relevant to the automation's purpose.
${
automationInspiration.length
? `The name should be in same style as existing automations.
Suggest labels if relevant to the automation's purpose.
Only suggest labels that are already used by existing automations.`
: `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.`
}
If the automation contains 5+ steps, include a short description.
For inspiration, here are existing automations:
${automationInspiration.join("\n")}
The automation configuration is as follows:
${dump(this._params.config)}
`,
structure: {
name: {
description: "The name of the automation",
required: true,
selector: {
text: {},
},
},
description: {
description: "A short description of the automation",
required: false,
selector: {
text: {},
},
},
labels: {
description: "Labels for the automation",
required: false,
selector: {
text: {
multiple: true,
},
},
},
},
});
this._newName = result.data.trim();
this._newName = result.data.name;
if (result.data.description) {
this._newDescription = result.data.description;
if (!this._visibleOptionals.includes("description")) {
this._visibleOptionals = [...this._visibleOptionals, "description"];
}
}
if (result.data.labels?.length) {
// We get back label names, convert them to IDs
const newLabels: Record<string, undefined | string> = Object.fromEntries(
result.data.labels.map((name) => [name, undefined])
);
let toFind = result.data.labels.length;
for (const [labelId, labelName] of Object.entries(labels)) {
if (labelName in newLabels && newLabels[labelName] === undefined) {
newLabels[labelName] = labelId;
toFind--;
if (toFind === 0) {
break;
}
}
}
const foundLabels = Object.values(newLabels).filter(
(labelId) => labelId !== undefined
);
if (foundLabels.length) {
this._entryUpdates = {
...this._entryUpdates,
labels: foundLabels,
};
if (!this._visibleOptionals.includes("labels")) {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
}
}
private async _save(): Promise<void> {

View File

@@ -502,6 +502,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.readOnly=${this._readOnly}
@value-changed=${this._yamlChanged}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`
: nothing}
</div>

View File

@@ -566,6 +566,7 @@ export default class HaAutomationTriggerRow extends LitElement {
text: html`
<ha-yaml-editor
read-only
disable-fullscreen
.hass=${this.hass}
.defaultValue=${this._triggered}
></ha-yaml-editor>

View File

@@ -173,6 +173,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
.content=${value?.description}
></ha-markdown>
${html`<ha-selector
narrow
.hass=${this.hass}
.selector=${selector}
.key=${key}

View File

@@ -394,7 +394,8 @@ class HaConfigSectionGeneral extends LitElement {
flex-direction: column;
display: flex;
}
ha-card {
ha-card,
ai-task-pref {
margin-bottom: 24px;
}
.card-content {

View File

@@ -58,7 +58,7 @@ export class DashboardCard extends LitElement {
.card-header {
padding: 12px;
display: block;
text-align: left;
text-align: var(--float-start);
gap: 8px;
}
.preview {

View File

@@ -54,6 +54,9 @@ class HaConfigNavigation extends LitElement {
`,
}));
return html`
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<ha-navigation-list
has-secondary
.hass=${this.hass}
@@ -68,6 +71,17 @@ class HaConfigNavigation extends LitElement {
ha-navigation-list {
--navigation-list-item-title-font-size: var(--ha-font-size-l);
}
/* Accessibility */
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
`;
}

View File

@@ -115,7 +115,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
></ha-spinner>`
: nothing}
</div>
<span
<span slot="headline"
>${deviceEntry
? computeDeviceNameDisplay(deviceEntry, this.hass)
: entity.attributes.friendly_name}</span

View File

@@ -10,7 +10,7 @@ import {
mdiPlusCircle,
mdiRestore,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -273,22 +273,24 @@ export class HaConfigDevicePage extends LitElement {
findBatteryChargingEntity(this.hass, entities)
);
public willUpdate(changedProps) {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("deviceId") || changedProps.has("entries")) {
if (changedProps.has("deviceId")) {
this._deviceActions = [];
this._deviceAlerts = [];
this._deleteButtons = [];
this._diagnosticDownloadLinks = [];
}
if (changedProps.has("deviceId") || changedProps.has("entries")) {
this._fetchData();
}
}
protected firstUpdated(changedProps) {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._fetchData();
}
protected updated(changedProps) {
@@ -989,6 +991,7 @@ export class HaConfigDevicePage extends LitElement {
}
private _getDeleteActions() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1058,12 +1061,18 @@ export class HaConfigDevicePage extends LitElement {
}
);
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
if (buttons.length > 0) {
this._deleteButtons = buttons;
}
}
private async _getDeviceActions() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1157,14 +1166,25 @@ export class HaConfigDevicePage extends LitElement {
// load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics
matter.getMatterDeviceActions(this, this.hass, device).then((actions) => {
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceActions = [...actions, ...(this._deviceActions || [])];
});
}
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceActions = deviceActions;
}
private async _getDeviceAlerts() {
const deviceId = this.deviceId;
const device = this.hass.devices[this.deviceId];
if (!device) {
@@ -1188,6 +1208,11 @@ export class HaConfigDevicePage extends LitElement {
deviceAlerts.push(...alerts);
}
if (this.deviceId !== deviceId) {
// abort if the device has changed
return;
}
this._deviceAlerts = deviceAlerts;
if (deviceAlerts.length) {
this._deviceAlertsActionsTimeout = window.setTimeout(() => {

View File

@@ -19,6 +19,7 @@ import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import {
PROTOCOL_INTEGRATIONS,
@@ -424,6 +425,18 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
let floorName = "—";
if (
device.area_id &&
areas[device.area_id]?.floor_id &&
this.hass.floors
) {
const floorId = areas[device.area_id].floor_id;
if (this.hass.floors[floorId!]) {
floorName = computeFloorName(this.hass.floors[floorId!]);
}
}
return {
...device,
name: computeDeviceNameDisplay(
@@ -441,6 +454,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
device.area_id && areas[device.area_id]
? areas[device.area_id].name
: "—",
floor: floorName,
integration: deviceEntries.length
? deviceEntries
.map(
@@ -524,6 +538,14 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
groupable: true,
minWidth: "120px",
},
floor: {
title: localize("ui.panel.config.devices.data_table.floor"),
sortable: true,
filterable: true,
groupable: true,
minWidth: "120px",
defaultHidden: true,
},
integration: {
title: localize("ui.panel.config.devices.data_table.integration"),
sortable: true,

View File

@@ -1099,10 +1099,10 @@ ${
}
protected firstUpdated() {
this._setFiltersFromUrl();
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
this._setFiltersFromUrl();
if (Object.keys(this._filters).length) {
return;
}
@@ -1116,7 +1116,7 @@ ${
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const device = this._searchParms.get("device");
const label = this._searchParms.has("label");
const label = this._searchParms.get("label");
if (!domain && !configEntry && !label && !device) {
return;
@@ -1128,21 +1128,10 @@ ${
"ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
this._filterLabel();
}
private _filterLabel() {
const label = this._searchParms.get("label");
if (!label) {
return;
}
this._filters = {
...this._filters,
"ha-filter-labels": [label],
};
}
private _clearFilter() {
@@ -1152,6 +1141,11 @@ ${
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setFiltersFromUrl();
}
const oldHass = changedProps.get("hass");
let changed = false;
if (!this.hass || !this._entities) {

View File

@@ -1,10 +1,11 @@
import {
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiPencil,
mdiShapeOutline,
mdiStopCircleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -57,111 +58,118 @@ class HaConfigEntryDeviceRow extends LitElement {
area ? area.name : undefined,
].filter(Boolean);
return html`<ha-md-list-item @click=${this.narrow ? this._handleNavigateToDevice : undefined} class=${classMap({ disabled: Boolean(device.disabled_by) })}>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
<div slot="headline"></div>${computeDeviceNameDisplay(device, this.hass)}</div>
return html`<ha-md-list-item
type="button"
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
<ha-svg-icon
.path=${device.entry_type === "service"
? mdiTransitConnectionVariant
: mdiDevices}
slot="start"
></ha-svg-icon>
<div slot="headline">${computeDeviceNameDisplay(device, this.hass)}</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${
entities.length
? this.narrow
? this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
: html`<a
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
: nothing
}</span
${entities.length
? this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
: nothing}</span
>
${
!this.narrow
? html`<ha-icon-button-next
slot="end"
@click=${this._handleNavigateToDevice}
>
</ha-icon-button-next>`
: nothing
}
</ha-icon-button>
${!this.narrow
? html`<ha-icon-next slot="end"> </ha-icon-next>`
: nothing}
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
${
!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleConfigureDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
></ha-icon-button>`
: nothing
}
</ha-icon-button>
<ha-md-button-menu positioning="popover" slot="end" @click=${stopPropagation}>
${!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleEditDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
></ha-icon-button>`
: nothing}
<ha-md-button-menu
positioning="popover"
slot="end"
@click=${stopPropagation}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.narrow
? html`<ha-md-menu-item @click=${this._handleConfigureDevice}>
<ha-svg-icon .path=${mdiCogOutline} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
</ha-md-menu-item>`
: nothing
}
<ha-md-menu-item class=${device.disabled_by !== "user" ? "warning" : ""} @click=${this._handleDisableDevice} .disabled=${device.disabled_by !== "user" && device.disabled_by}>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${
device.disabled_by && device.disabled_by !== "user"
? this.hass.localize(
"ui.dialogs.device-registry-detail.enabled_cause",
{
type: this.hass.localize(
`ui.dialogs.device-registry-detail.type.${
device.entry_type || "device"
}`
),
cause: this.hass.localize(
`config_entry.disabled_by.${device.disabled_by}`
),
}
)
: device.disabled_by
? this.hass.localize(
"ui.panel.config.integrations.config_entry.device.enable"
)
: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.disable"
)
}
</ha-md-menu-item>
${
this.entry.supports_remove_device
? html` <ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
${this.narrow
? html`<ha-md-menu-item @click=${this._handleEditDevice}>
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
)}
</ha-md-menu-item>`
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
</ha-md-menu-item>`
: nothing
}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
class=${device.disabled_by !== "user" ? "warning" : ""}
@click=${this._handleDisableDevice}
.disabled=${device.disabled_by !== "user" && device.disabled_by}
>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${device.disabled_by && device.disabled_by !== "user"
? this.hass.localize(
"ui.dialogs.device-registry-detail.enabled_cause",
{
type: this.hass.localize(
`ui.dialogs.device-registry-detail.type.${
device.entry_type || "device"
}`
),
cause: this.hass.localize(
`config_entry.disabled_by.${device.disabled_by}`
),
}
)
: device.disabled_by
? this.hass.localize(
"ui.panel.config.integrations.config_entry.device.enable"
)
: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.disable"
)}
</ha-md-menu-item>
${this.entry.supports_remove_device
? html`<ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
)}
</ha-md-menu-item>`
: nothing}
</ha-md-button-menu>
</ha-md-list-item> `;
}
@@ -169,7 +177,7 @@ class HaConfigEntryDeviceRow extends LitElement {
private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleConfigureDevice(ev: MouseEvent) {
private _handleEditDevice(ev: MouseEvent) {
ev.stopPropagation(); // Prevent triggering the click handler on the list item
showDeviceRegistryDetailDialog(this, {
device: this.device,
@@ -294,6 +302,8 @@ class HaConfigEntryDeviceRow extends LitElement {
}
ha-md-list-item {
--md-list-item-leading-space: 56px;
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
.disabled {
opacity: 0.5;

View File

@@ -1,7 +1,6 @@
import {
mdiAlertCircle,
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
@@ -58,6 +57,7 @@ import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entr
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
@@ -69,7 +69,6 @@ import {
import "./ha-config-entry-device-row";
import { renderConfigEntryError } from "./ha-config-integration-page";
import "./ha-config-sub-entry-row";
import { haStyle } from "../../../resources/styles";
@customElement("ha-config-entry-row")
class HaConfigEntryRow extends LitElement {
@@ -155,7 +154,10 @@ class HaConfigEntryRow extends LitElement {
statusLine.push(
html`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
>${entities.length} entities</a
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
@@ -178,8 +180,8 @@ class HaConfigEntryRow extends LitElement {
>
${subEntries.length || ownDevices.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
@@ -410,12 +412,15 @@ class HaConfigEntryRow extends LitElement {
<ha-md-list-item
@click=${this._toggleOwnDevices}
type="button"
class="toggle-devices-row ${classMap({
expanded: this._devicesExpanded,
})}"
>
<ha-icon-button
class="expand-button"
.path=${this._devicesExpanded
? mdiChevronDown
: mdiChevronUp}
class="expand-button ${classMap({
expanded: this._devicesExpanded,
})}"
.path=${mdiChevronDown}
slot="start"
>
</ha-icon-button>
@@ -739,6 +744,10 @@ class HaConfigEntryRow extends LitElement {
css`
.expand-button {
margin: 0 -12px;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.expand-button.expanded {
transform: rotate(180deg);
}
ha-md-list {
border: 1px solid var(--divider-color);
@@ -759,6 +768,14 @@ class HaConfigEntryRow extends LitElement {
var(--md-sys-color-on-surface-variant, #49454f)
);
}
.toggle-devices-row {
overflow: hidden;
border-radius: var(--ha-card-border-radius, 12px);
}
.toggle-devices-row.expanded {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
`,
];
}

View File

@@ -380,6 +380,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
<div class="title">
<h1>${domainToName(this.hass.localize, this.domain)}</h1>
<div class="sub">
${this._manifest?.version != null
? html`<span class="version"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.version",
{ version: this._manifest.version }
)}</span
>`
: nothing}
${this._manifest?.is_built_in === false
? html`<div
class=${`integration-info ${
@@ -424,7 +432,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}
</div>`
: nothing}
${this._manifest?.quality_scale &&
${this._manifest?.is_built_in &&
this._manifest.quality_scale &&
Object.keys(QUALITY_SCALE_MAP).includes(
this._manifest.quality_scale
)
@@ -893,12 +902,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
.title {
display: flex;
gap: 8px;
gap: 4px;
flex-direction: column;
justify-content: space-between;
}
.title h1 {
font-family: Roboto;
font-family: var(--ha-font-family-body);
font-size: 32px;
font-weight: 700;
line-height: 40px;
@@ -911,6 +920,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: center;
}
.card-content {
padding: 16px 0 8px;
@@ -928,15 +938,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
display: flex;
justify-content: center;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
padding: 0 8px;
}
.logo-container img {
width: 80px;
}
.version {
padding-top: 8px;
display: flex;
justify-content: center;
color: var(--secondary-text-color);
}
.overview .card-actions {
@@ -964,7 +973,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
margin-inline-start: 16px;
margin-top: 6px;
margin-bottom: 6px;
font-family: Roboto;
font-family: var(--ha-font-family-body);
font-size: 14px;
font-weight: 500;
line-height: 20px;

View File

@@ -406,11 +406,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
${!this._showDisabled && this.narrow && disabledConfigEntries.length
? html`<span class="badge">${disabledConfigEntries.length}</span>`
: ""}
<ha-button-menu
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-button-menu multi @action=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}

View File

@@ -1,6 +1,5 @@
import {
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
@@ -10,6 +9,7 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { deleteSubEntry } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
@@ -56,8 +56,8 @@ class HaConfigSubEntryRow extends LitElement {
>
${devices.length || services.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
class="expand-button ${classMap({ expanded: this._expanded })}"
.path=${mdiChevronDown}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
@@ -239,6 +239,10 @@ class HaConfigSubEntryRow extends LitElement {
static styles = css`
.expand-button {
margin: 0 -12px;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.expand-button.expanded {
transform: rotate(180deg);
}
ha-md-list {
border: 1px solid var(--divider-color);

View File

@@ -321,6 +321,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
.defaultValue=${this._config}
@value-changed=${this._yamlChanged}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`;
}

View File

@@ -440,6 +440,7 @@ export class HaScriptEditor extends SubscribeMixin(
.hass=${this.hass}
.defaultValue=${this._preprocessYaml()}
.readOnly=${this._readOnly}
disable-fullscreen
@value-changed=${this._yamlChanged}
.showErrors=${false}
></ha-yaml-editor>`

View File

@@ -25,9 +25,6 @@ export const cardFeatureStyles = css`
flex-basis: 20px;
--control-button-padding: 0px;
}
ha-control-button-group[no-stretch] > ha-control-button {
max-width: 48px;
}
ha-control-button {
--control-button-focus-color: var(--feature-color);
}

View File

@@ -1,21 +1,22 @@
import type { HassEntity } 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 { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { generateEntityFilter } from "../../../common/entity/entity_filter";
import {
computeGroupEntitiesState,
toggleGroupEntities,
} from "../../../common/entity/group_entities";
import { computeGroupEntitiesState } from "../../../common/entity/group_entities";
import { stateActive } from "../../../common/entity/state_active";
import { domainColorProperties } from "../../../common/entity/state_color";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-domain-icon";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import { forwardHaptic } from "../../../data/haptics";
import type { GroupToggleDialogParams } from "../../../dialogs/more-info/components/voice/ha-more-info-view-toggle-group";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";
import type { AreaCardFeatureContext } from "../cards/hui-area-card";
@@ -30,39 +31,24 @@ import type {
import { AREA_CONTROLS } from "./types";
interface AreaControlsButton {
offIcon?: string;
onIcon?: string;
filter: {
domain: string;
device_class?: string;
};
domain: string;
device_class?: string;
}
const coverButton = (deviceClass: string) => ({
filter: {
domain: "cover",
device_class: deviceClass,
},
domain: "cover",
device_class: deviceClass,
});
export const AREA_CONTROLS_BUTTONS: Record<AreaControl, AreaControlsButton> = {
light: {
// Overrides the icons for lights
offIcon: "mdi:lightbulb-off",
onIcon: "mdi:lightbulb",
filter: {
domain: "light",
},
domain: "light",
},
fan: {
filter: {
domain: "fan",
},
domain: "fan",
},
switch: {
filter: {
domain: "switch",
},
domain: "switch",
},
"cover-blind": coverButton("blind"),
"cover-curtain": coverButton("curtain"),
@@ -96,7 +82,8 @@ export const getAreaControlEntities = (
const filter = generateEntityFilter(hass, {
area: areaId,
entity_category: "none",
...controlButton.filter,
domain: controlButton.domain,
device_class: controlButton.device_class,
});
acc[control] = Object.keys(hass.entities).filter(
@@ -158,9 +145,7 @@ class HuiAreaControlsCardFeature
this._config = config;
}
private _handleButtonTap(ev: MouseEvent) {
ev.stopPropagation();
private async _handleButtonTap(ev: MouseEvent) {
if (!this.context?.area_id || !this.hass || !this._config) {
return;
}
@@ -176,12 +161,27 @@ class HuiAreaControlsCardFeature
);
const entitiesIds = controlEntities[control];
const entities = entitiesIds
.map((entityId) => this.hass!.states[entityId] as HassEntity | undefined)
.filter((v): v is HassEntity => Boolean(v));
const { domain, device_class: dc } = AREA_CONTROLS_BUTTONS[control];
forwardHaptic("light");
toggleGroupEntities(this.hass, entities);
const domainName = this.hass.localize(
`component.${domain}.entity_component.${dc ?? "_"}.name`
);
showMoreInfoDialog(this, {
entityId: null,
parentView: {
title: computeAreaName(this._area!) || "",
subtitle: domainName,
tag: "ha-more-info-view-toggle-group",
import: () =>
import(
"../../../dialogs/more-info/components/voice/ha-more-info-view-toggle-group"
),
params: {
entityIds: entitiesIds,
} as GroupToggleDialogParams,
},
});
}
private _controlEntities = memoizeOne(
@@ -229,7 +229,11 @@ class HuiAreaControlsCardFeature
}
return html`
<ha-control-button-group ?no-stretch=${this.position === "inline"}>
<ha-control-button-group
class=${classMap({
"no-stretch": this.position === "inline",
})}
>
${displayControls.map((control) => {
const button = AREA_CONTROLS_BUTTONS[control];
@@ -248,15 +252,22 @@ class HuiAreaControlsCardFeature
? stateActive(entities[0], groupState)
: false;
const label = this.hass!.localize(
`ui.card_features.area_controls.${control}.${active ? "off" : "on"}`
const domain = button.domain;
const dc = button.device_class;
const domainName = this.hass!.localize(
`component.${domain}.entity_component.${dc ?? "_"}.name`
);
const icon = active ? button.onIcon : button.offIcon;
const label = `${domainName}: ${this.hass!.localize(
`ui.card_features.area_controls.open_more_info`
)}`;
const domain = button.filter.domain;
const deviceClass = button.filter.device_class
? ensureArray(button.filter.device_class)[0]
const icon =
domain === "light" && !active ? "mdi:lightbulb-off" : undefined;
const deviceClass = button.device_class
? ensureArray(button.device_class)[0]
: undefined;
const activeColor = computeCssVariable(
@@ -292,8 +303,22 @@ class HuiAreaControlsCardFeature
return [
cardFeatureStyles,
css`
:host {
pointer-events: none !important;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
ha-control-button-group {
--control-button-group-alignment: flex-end;
pointer-events: auto;
width: 100%;
}
ha-control-button-group.no-stretch {
width: auto;
max-width: 100%;
}
ha-control-button-group.no-stretch > ha-control-button {
width: 48px;
}
ha-control-button {
--active-color: var(--state-active-color);

View File

@@ -1,4 +1,4 @@
import { LitElement, html, nothing } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import type { HuiErrorCard } from "../cards/hui-error-card";
@@ -56,6 +56,12 @@ export class HuiCardFeature extends LitElement {
}
return html`${element}`;
}
static styles = css`
:host > * {
pointer-events: auto;
}
`;
}
declare global {

View File

@@ -46,6 +46,7 @@ export class HuiCardFeatures extends LitElement {
--feature-height: 42px;
--feature-border-radius: 12px;
--feature-button-spacing: 12px;
pointer-events: none;
position: relative;
width: 100%;
display: flex;

View File

@@ -255,6 +255,20 @@ class HuiEnergyDistrubutionCard
(batteryFromGrid || 0) +
(batteryToGrid || 0);
// Coerce all energy numbers to the same unit (the biggest)
const maxEnergy = Math.max(
lowCarbonEnergy || 0,
totalSolarProduction || 0,
returnedToGrid || 0,
totalFromGrid || 0,
totalHomeConsumption,
totalBatteryIn || 0,
totalBatteryOut || 0
);
const targetEnergyUnit = formatConsumptionShort(this.hass, maxEnergy, "kWh")
.split(" ")
.pop();
return html`
<ha-card .header=${this._config.title}>
<div class="card-content">
@@ -281,7 +295,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
lowCarbonEnergy,
"kWh"
"kWh",
targetEnergyUnit
)}
</a>
<svg width="80" height="30">
@@ -300,7 +315,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
totalSolarProduction,
"kWh"
"kWh",
targetEnergyUnit
)}
</div>
</div>`
@@ -396,7 +412,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
returnedToGrid,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>`
: ""}
@@ -409,7 +426,8 @@ class HuiEnergyDistrubutionCard
: ""}${formatConsumptionShort(
this.hass,
totalFromGrid,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
</div>
@@ -432,7 +450,8 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
totalHomeConsumption,
"kWh"
"kWh",
targetEnergyUnit
)}
${homeSolarCircumference !== undefined ||
homeLowCarbonCircumference !== undefined
@@ -535,7 +554,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
totalBatteryIn,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
<span class="battery-out">
@@ -546,7 +566,8 @@ class HuiEnergyDistrubutionCard
>${formatConsumptionShort(
this.hass,
totalBatteryOut,
"kWh"
"kWh",
targetEnergyUnit
)}
</span>
</div>

View File

@@ -348,6 +348,14 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return undefined;
}
// If only one entity, return its formatted state
if (entities.length === 1) {
const stateObj = entities[0];
return isUnavailableState(stateObj.state)
? ""
: this.hass.formatEntityState(stateObj);
}
// Use the first entity's unit_of_measurement for formatting
const uom = entities.find(
(entity) => entity.attributes.unit_of_measurement

View File

@@ -239,7 +239,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
)}
.path=${mdiImageFilterCenterFocus}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._fitMap}
@click=${this._resetFocus}
tabindex="0"
></ha-icon-button>
</div>
@@ -389,8 +389,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
: (root.style.paddingBottom = "100%");
}
private _fitMap() {
this._map?.fitMap();
private _resetFocus() {
this._map?.fitMap({ unpause_autofit: true });
}
private _toggleClusterMarkers() {

View File

@@ -82,7 +82,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
const width = entries[0]?.contentRect.width;
if (width < 245) {
result.height = "very-very-narrow";
result.width = "very-very-narrow";
} else if (width < 300) {
result.width = "very-narrow";
} else if (width < 375) {
@@ -93,7 +93,6 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
if (height < 235) {
result.height = "short";
}
return result;
},
});
@@ -243,11 +242,11 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
);
let itemsToShow = this._config?.forecast_slots ?? 5;
if (this._sizeController.value.width === "very-very-narrow") {
if (this._sizeController.value?.width === "very-very-narrow") {
itemsToShow = Math.min(3, itemsToShow);
} else if (this._sizeController.value.width === "very-narrow") {
} else if (this._sizeController.value?.width === "very-narrow") {
itemsToShow = Math.min(5, itemsToShow);
} else if (this._sizeController.value.width === "narrow") {
} else if (this._sizeController.value?.width === "narrow") {
itemsToShow = Math.min(7, itemsToShow);
}
@@ -266,8 +265,12 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
return html`
<ha-card
class=${classMap({
[this._sizeController.value.height]: true,
[this._sizeController.value.width]: true,
[this._sizeController.value?.height]: Boolean(
this._sizeController.value
),
[this._sizeController.value?.width]: Boolean(
this._sizeController.value
),
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
@@ -461,10 +464,11 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
if (this._config?.show_forecast !== false) {
rows += 1;
min_rows += 1;
if (this._config?.forecast_type === "daily") {
rows += 1;
}
}
if (this._config?.forecast_type === "daily") {
rows += 1;
}
return {
columns: 12,
rows: rows,

View File

@@ -74,8 +74,6 @@ export class HuiDialogEditCard
@state() private _dirty = false;
@state() private _isEscapeEnabled = true;
public async showDialog(params: EditCardDialogParams): Promise<void> {
this._params = params;
this._GUImode = true;
@@ -93,9 +91,6 @@ export class HuiDialogEditCard
}
public closeDialog(): boolean {
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("hass-more-info", this._disableEscapeKeyClose);
if (this._dirty) {
this._confirmCancel();
return false;
@@ -124,16 +119,6 @@ export class HuiDialogEditCard
}
}
private _enableEscapeKeyClose = (ev: any) => {
if (ev.detail.dialog === "ha-more-info-dialog") {
this._isEscapeEnabled = true;
}
};
private _disableEscapeKeyClose = () => {
this._isEscapeEnabled = false;
};
protected render() {
if (!this._params || !this._cardConfig) {
return nothing;
@@ -170,7 +155,7 @@ export class HuiDialogEditCard
<ha-dialog
open
scrimClickAction
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
escapeKeyAction
@keydown=${this._ignoreKeydown}
@closed=${this._cancel}
@opened=${this._opened}
@@ -304,8 +289,6 @@ export class HuiDialogEditCard
}
private _opened() {
window.addEventListener("dialog-closed", this._enableEscapeKeyClose);
window.addEventListener("hass-more-info", this._disableEscapeKeyClose);
this._cardEditorEl?.focusYamlEditor();
}

View File

@@ -57,7 +57,7 @@ export class HuiWeatherForecastCardEditor
if (
/* cannot show forecast in case it is unavailable on the entity */
(config.show_forecast === true && this._hasForecast === false) ||
(config.show_forecast !== false && this._hasForecast === false) ||
/* cannot hide both weather and forecast, need one of them */
(config.show_current === false && config.show_forecast === false)
) {
@@ -65,6 +65,7 @@ export class HuiWeatherForecastCardEditor
fireEvent(this, "config-changed", {
config: { ...config, show_current: true, show_forecast: false },
});
return;
}
if (
!config.forecast_type ||

View File

@@ -144,6 +144,9 @@ class DialogDashboardStrategyEditor extends LitElement {
.path=${mdiClose}
></ha-icon-button>
<span slot="title" .title=${title}>${title}</span>
${this._params.title
? html`<span slot="subtitle">${this._params.title}</span>`
: nothing}
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"

View File

@@ -3,6 +3,7 @@ import type { LovelaceDashboardStrategyConfig } from "../../../../../data/lovela
export interface DashboardStrategyEditorDialogParams {
config: LovelaceDashboardStrategyConfig;
title?: string;
saveConfig: (config: LovelaceDashboardStrategyConfig) => void;
takeControl: () => void;
deleteDashboard: () => Promise<boolean>;

View File

@@ -89,6 +89,7 @@ class LovelaceFullConfigEditor extends LitElement {
.hass=${this.hass}
@value-changed=${this._yamlChanged}
@editor-save=${this._handleSave}
disable-fullscreen
dir="ltr"
>
</ha-code-editor>

View File

@@ -782,6 +782,7 @@ class HUIRoot extends LitElement {
showDashboardStrategyEditorDialog(this, {
config: this.lovelace!.rawConfig,
title: this.panel ? getPanelTitle(this.hass, this.panel) : undefined,
saveConfig: this.lovelace!.saveConfig,
takeControl: () => {
showSaveDialog(this, {

View File

@@ -1,5 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
@@ -148,7 +149,22 @@ export class AreaViewStrategy extends ReactiveElement {
hass.localize("ui.panel.lovelace.strategy.areas.groups.security"),
AREA_STRATEGY_GROUP_ICONS.security
),
...security.map(computeTileCard),
...security.map((entityId) => {
const domain = computeDomain(entityId);
if (domain === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_state: false,
show_name: false,
grid_options: {
columns: 6,
rows: 2,
},
};
}
return computeTileCard(entityId);
}),
],
});
}

View File

@@ -22,6 +22,9 @@ export interface AreasDashboardStrategyConfig {
hidden?: string[];
order?: string[];
};
floors_display?: {
order?: string[];
};
areas_options?: Record<string, AreaOptions>;
}
@@ -78,13 +81,13 @@ export class AreasDashboardStrategy extends ReactiveElement {
return {
views: [
{
title: "Home",
icon: "mdi:home",
path: "home",
strategy: {
type: "areas-overview",
areas_display: config.areas_display,
areas_options: config.areas_options,
floors_display: config.floors_display,
} satisfies AreasViewStrategyConfig,
},
...areaViews,

View File

@@ -1,6 +1,5 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { stringCompare } from "../../../../common/string/compare";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
@@ -9,7 +8,11 @@ import { getAreaControlEntities } from "../../card-features/hui-area-controls-ca
import { AREA_CONTROLS, type AreaControl } from "../../card-features/types";
import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types";
import type { EntitiesDisplay } from "./area-view-strategy";
import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helper";
import {
computeAreaPath,
getAreas,
getFloors,
} from "./helpers/areas-strategy-helper";
const UNASSIGNED_FLOOR = "__unassigned__";
@@ -23,6 +26,9 @@ export interface AreasViewStrategyConfig {
hidden?: string[];
order?: string[];
};
floors_display?: {
order?: string[];
};
areas_options?: Record<string, AreaOptions>;
}
@@ -32,43 +38,35 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
config: AreasViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = getAreas(
const displayedAreas = getAreas(
hass.areas,
config.areas_display?.hidden,
config.areas_display?.order
);
const floors = Object.values(hass.floors);
floors.sort((floorA, floorB) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const floors = getFloors(hass.floors, config.floors_display?.order);
const floorSections = [
...floors,
{
floor_id: UNASSIGNED_FLOOR,
name: hass.localize(
"ui.panel.lovelace.strategy.areas.unassigned_areas"
),
name: hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
level: null,
icon: null,
},
]
.map<LovelaceSectionConfig | undefined>((floor) => {
const areasInFloors = areas.filter(
.map((floor) => {
const areasInFloors = displayedAreas.filter(
(area) =>
area.floor_id === floor.floor_id ||
(!area.floor_id && floor.floor_id === UNASSIGNED_FLOOR)
);
if (areasInFloors.length === 0) {
return undefined;
}
const areasCards = areasInFloors.map<AreaCardConfig>((area) => {
return [floor, areasInFloors] as const;
})
.filter(([_, areas]) => areas.length)
.map<LovelaceSectionConfig | undefined>(([floor, areas], _, array) => {
const areasCards = areas.map<AreaCardConfig>((area) => {
const path = computeAreaPath(area.area_id);
const areaOptions = config.areas_options?.[area.area_id] || {};
@@ -91,11 +89,19 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
(control) => controlEntities[control].length > 0
);
const sensorClasses: string[] = [];
if (area.temperature_entity_id) {
sensorClasses.push("temperature");
}
if (area.humidity_entity_id) {
sensorClasses.push("humidity");
}
return {
type: "area",
area: area.area_id,
display_type: "compact",
sensor_classes: ["temperature", "humidity"],
sensor_classes: sensorClasses,
exclude_entities: hiddenEntities,
features: filteredControls.length
? [
@@ -114,10 +120,17 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
};
});
const noFloors =
array.length === 1 && floor.floor_id === UNASSIGNED_FLOOR;
const headingTitle = noFloors
? hass.localize("ui.panel.lovelace.strategy.areas.areas")
: floor.name;
const headingCard: HeadingCardConfig = {
type: "heading",
heading_style: "title",
heading: floor.name,
heading: headingTitle,
icon: floor.icon || floorDefaultIcon(floor),
};

View File

@@ -1,28 +1,32 @@
import { mdiThermometerWater } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-areas-display-editor";
import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor";
import "../../../../../components/ha-areas-floors-display-editor";
import type { AreasFloorsDisplayValue } from "../../../../../components/ha-areas-floors-display-editor";
import "../../../../../components/ha-entities-display-editor";
import "../../../../../components/ha-icon";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-button-prev";
import "../../../../../components/ha-icon";
import "../../../../../components/ha-svg-icon";
import {
updateAreaRegistryEntry,
type AreaRegistryEntry,
} from "../../../../../data/area_registry";
import { buttonLinkStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { showAreaRegistryDetailDialog } from "../../../../config/areas/show-dialog-area-registry-detail";
import type { LovelaceStrategyEditor } from "../../types";
import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy";
import type { AreaStrategyGroup } from "../helpers/areas-strategy-helper";
import {
AREA_STRATEGY_GROUP_ICONS,
AREA_STRATEGY_GROUPS,
getAreaGroupedEntities,
} from "../helpers/areas-strategy-helper";
import type { LovelaceStrategyEditor } from "../../types";
import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy";
import { showAreaRegistryDetailDialog } from "../../../../config/areas/show-dialog-area-registry-detail";
import {
updateAreaRegistryEntry,
type AreaRegistryEntry,
} from "../../../../../data/area_registry";
import { buttonLinkStyle } from "../../../../../resources/styles";
import "../../../../../components/ha-areas-floors-display-editor";
@customElement("hui-areas-dashboard-strategy-editor")
export class HuiAreasDashboardStrategyEditor
@@ -58,14 +62,18 @@ export class HuiAreasDashboardStrategyEditor
</div>
<ha-expansion-panel
.header=${this.hass!.localize(
`ui.panel.lovelace.strategy.areas.header`
`ui.panel.lovelace.strategy.areas.sensors`
)}
expanded
outlined
>
<ha-svg-icon
slot="leading-icon"
.path=${mdiThermometerWater}
></ha-svg-icon>
<p>
${this.hass!.localize(
`ui.panel.lovelace.strategy.areas.header_description`,
`ui.panel.lovelace.strategy.areas.sensors_description`,
{
edit_the_area: html`
<button class="link" @click=${this._editArea} .area=${area}>
@@ -120,7 +128,7 @@ export class HuiAreasDashboardStrategyEditor
`;
}
const value = this._config.areas_display;
const value = this._areasFloorsDisplayValue(this._config);
return html`
<ha-areas-floors-display-editor
@@ -129,7 +137,7 @@ export class HuiAreasDashboardStrategyEditor
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.areas.areas_display"
)}
@value-changed=${this._areasDisplayChanged}
@value-changed=${this._areasFloorsDisplayChanged}
expanded
show-navigation-button
@item-display-navigate-clicked=${this._handleAreaNavigate}
@@ -143,6 +151,13 @@ export class HuiAreasDashboardStrategyEditor
}
}
private _areasFloorsDisplayValue = memoizeOne(
(config: AreasDashboardStrategyConfig): AreasFloorsDisplayValue => ({
areas_display: config.areas_display,
floors_display: config.floors_display,
})
);
private _editArea(ev: Event): void {
ev.stopPropagation();
const area = (ev.currentTarget! as any).area as AreaRegistryEntry;
@@ -157,11 +172,11 @@ export class HuiAreasDashboardStrategyEditor
this._area = ev.detail.value;
}
private _areasDisplayChanged(ev: CustomEvent): void {
const value = ev.detail.value as AreasDisplayValue;
private _areasFloorsDisplayChanged(ev: CustomEvent): void {
const value = ev.detail.value as AreasFloorsDisplayValue;
const newConfig: AreasDashboardStrategyConfig = {
...this._config!,
areas_display: value,
...value,
};
fireEvent(this, "config-changed", { config: newConfig });
@@ -213,9 +228,13 @@ export class HuiAreasDashboardStrategyEditor
ha-expansion-panel {
margin-bottom: 8px;
max-width: 600px;
--expansion-panel-summary-padding: 0 16px;
}
ha-expansion-panel [slot="leading-icon"] {
margin-inline-end: 16px;
}
ha-expansion-panel p {
margin: 8px 2px;
margin: 8px 8px 16px 8px;
}
button.link {
color: var(--primary-color);

View File

@@ -1,12 +1,14 @@
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeStateName } from "../../../../../common/entity/compute_state_name";
import type { EntityFilterFunc } from "../../../../../common/entity/entity_filter";
import { generateEntityFilter } from "../../../../../common/entity/entity_filter";
import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name";
import { orderCompare } from "../../../../../common/string/compare";
import {
orderCompare,
stringCompare,
} from "../../../../../common/string/compare";
import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import { areaCompare } from "../../../../../data/area_registry";
import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
import type { HomeAssistant } from "../../../../../types";
import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../../card-features/hui-cover-open-close-card-feature";
@@ -206,7 +208,7 @@ export const getAreaGroupedEntities = (
export const computeAreaTileCardConfig =
(hass: HomeAssistant, prefix: string, includeFeature?: boolean) =>
(entity: string): LovelaceCardConfig => {
(entity: string): TileCardConfig => {
const stateObj = hass.states[entity];
const context: LovelaceCardFeatureContext = {
@@ -215,21 +217,6 @@ export const computeAreaTileCardConfig =
const additionalCardConfig: Partial<TileCardConfig> = {};
const domain = computeDomain(entity);
if (domain === "camera") {
return {
type: "picture-entity",
entity: entity,
show_state: false,
show_name: false,
grid_options: {
columns: 6,
rows: 2,
},
};
}
let feature: LovelaceCardFeatureConfig | undefined;
if (includeFeature) {
if (supportsLightBrightnessCardFeature(hass, context)) {
@@ -290,4 +277,23 @@ export const getAreas = (
return sortedAreas;
};
export const getFloors = (
entries: HomeAssistant["floors"],
floorsOrder?: string[]
): FloorRegistryEntry[] => {
const floors = Object.values(entries);
const compare = orderCompare(floorsOrder || []);
return floors.sort((floorA, floorB) => {
const order = compare(floorA.floor_id, floorB.floor_id);
if (order !== 0) {
return order;
}
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
};
export const computeAreaPath = (areaId: string): string => `areas-${areaId}`;

View File

@@ -45,15 +45,38 @@ import type { BarMediaPlayer } from "./ha-bar-media-player";
import { showWebBrowserPlayMediaDialog } from "./show-media-player-dialog";
const createMediaPanelUrl = (entityId: string, items: MediaPlayerItemId[]) => {
let path = `/media-browser/${entityId}`;
for (const item of items.slice(1)) {
path +=
"/" +
encodeURIComponent(`${item.media_content_type},${item.media_content_id}`);
const path = `/media-browser/${entityId}`;
if (items.length <= 1) {
return path;
}
return path;
const navigateIds = items
.slice(1)
.map((item) =>
encodeURIComponent(`${item.media_content_type},${item.media_content_id}`)
);
const urlparams = new URLSearchParams();
urlparams.set("ids", navigateIds.join(","));
return path + "?" + urlparams.toString();
};
const decodeNavigateIds = (
navigateIdsEncoded: string[]
): MediaPlayerItemId[] => [
{
media_content_id: undefined,
media_content_type: undefined,
},
...navigateIdsEncoded.map((navigateId) => {
const decoded = decodeURIComponent(navigateId);
// Don't use split because media_content_id could contain commas
const delimiter = decoded.indexOf(",");
return {
media_content_type: decoded.substring(0, delimiter),
media_content_id: decoded.substring(delimiter + 1),
};
}),
];
@customElement("ha-panel-media-browser")
class PanelMediaBrowser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -211,9 +234,19 @@ class PanelMediaBrowser extends LitElement {
return;
}
const [routePlayer, ...navigateIdsEncoded] = this.route.path
.substring(1)
.split("/");
const [routePlayer, ...paths] = this.route.path.substring(1).split("/");
const navigateIdsEncoded =
new URLSearchParams(location.search).get("ids")?.split(",") || [];
// Backwards compatibility with old URLs
if (navigateIdsEncoded.length === 0 && paths.length > 0) {
const navigateIds = decodeNavigateIds(paths);
navigate(createMediaPanelUrl(this._entityId, navigateIds), {
replace: true,
});
return;
}
if (routePlayer !== this._entityId) {
// Detect if picked player doesn't exist (anymore)
@@ -236,21 +269,7 @@ class PanelMediaBrowser extends LitElement {
this._entityId = routePlayer;
}
this._navigateIds = [
{
media_content_type: undefined,
media_content_id: undefined,
},
...navigateIdsEncoded.map((navigateId) => {
const decoded = decodeURIComponent(navigateId);
// Don't use split because media_content_id could contain commas
const delimiter = decoded.indexOf(",");
return {
media_content_type: decoded.substring(0, delimiter),
media_content_id: decoded.substring(delimiter + 1),
};
}),
];
this._navigateIds = decodeNavigateIds(navigateIdsEncoded);
this._currentItem = undefined;
}
@@ -258,6 +277,7 @@ class PanelMediaBrowser extends LitElement {
navigate(
createMediaPanelUrl(this._entityId, this._navigateIds.slice(0, -1))
);
this.requestUpdate("route");
}
private _mediaBrowsed(ev: { detail: HASSDomEvents["media-browsed"] }) {
@@ -269,6 +289,7 @@ class PanelMediaBrowser extends LitElement {
navigate(createMediaPanelUrl(this._entityId, ev.detail.ids), {
replace: ev.detail.replace,
});
this.requestUpdate("route");
}
private async _mediaPicked(

View File

@@ -51,7 +51,7 @@ export const haTheme = EditorView.theme({
"&": {
color: "var(--primary-text-color)",
backgroundColor:
"var(--code-editor-background-color, var(--mdc-text-field-fill-color, whitesmoke))",
"var(--code-editor-background-color, var(--card-background-color))",
borderRadius:
"var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0px 0px",
caretColor: "var(--secondary-text-color)",

View File

@@ -30,6 +30,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
{
entityId: ev.detail.entityId,
view: ev.detail.view || ev.detail.tab,
parentView: ev.detail.parentView,
},
() => import("../dialogs/more-info/ha-more-info-dialog")
);

View File

@@ -51,7 +51,7 @@
"owner": "Owner",
"system-admin": "Administrators",
"system-users": "Users",
"system-read-only": "Read-Only Users"
"system-read-only": "Read-only users"
},
"config_entry": {
"disabled_by": {
@@ -79,6 +79,8 @@
"common": {
"turn_on": "Turn on",
"turn_off": "Turn off",
"turn_on_all": "Turn on all",
"turn_off_all": "Turn off all",
"toggle": "Toggle",
"entity_not_found": "Entity not found"
},
@@ -145,7 +147,11 @@
"close_cover": "Close cover",
"open_tilt_cover": "Open cover tilt",
"close_tilt_cover": "Close cover tilt",
"stop_cover": "Stop cover"
"stop_cover": "Stop cover",
"open": "Open",
"open_all": "Open all",
"close": "Close",
"close_all": "Close all"
},
"fan": {
"preset_mode": "Preset mode",
@@ -327,58 +333,7 @@
},
"card_features": {
"area_controls": {
"light": {
"on": "Turn on area lights",
"off": "Turn off area lights"
},
"fan": {
"on": "Turn on area fans",
"off": "Turn off area fans"
},
"switch": {
"on": "Turn on area switches",
"off": "Turn off area switches"
},
"cover-awning": {
"on": "Open area awnings",
"off": "Close area awnings"
},
"cover-blind": {
"on": "Open area blinds",
"off": "Close area blinds"
},
"cover-curtain": {
"on": "Open area curtains",
"off": "Close area curtains"
},
"cover-damper": {
"on": "Open area dampers",
"off": "Close area dampers"
},
"cover-door": {
"on": "Open area doors",
"off": "Close area doors"
},
"cover-garage": {
"on": "Open garage door",
"off": "Close garage door"
},
"cover-gate": {
"on": "Open area gates",
"off": "Close area gates"
},
"cover-shade": {
"on": "Open area shades",
"off": "Close area shades"
},
"cover-shutter": {
"on": "Open area shutters",
"off": "Close area shutters"
},
"cover-window": {
"on": "Open area windows",
"off": "Close area windows"
}
"open_more_info": "Open more info"
}
},
"common": {
@@ -471,7 +426,7 @@
"radius_meters": "[%key:ui::panel::config::core::section::core::core_config::elevation_meters%]"
},
"selector": {
"options": "Selector Options",
"options": "Selector options",
"types": {
"action": "Action",
"area": "Area",
@@ -480,7 +435,7 @@
"color_temp": "Color temperature",
"condition": "Condition",
"date": "Date",
"datetime": "Date and Time",
"datetime": "Date and time",
"device": "Device",
"duration": "Duration",
"entity": "Entity",
@@ -490,7 +445,7 @@
"media": "Media",
"number": "Number",
"object": "Object",
"color_rgb": "RGB Color",
"color_rgb": "RGB color",
"select": "Select",
"state": "State",
"target": "Target",
@@ -498,7 +453,7 @@
"text": "Text",
"theme": "Theme",
"time": "Time",
"manual": "Manual Entry"
"manual": "Manual entry"
}
},
"template": {
@@ -857,7 +812,7 @@
"cyan": "Cyan",
"teal": "Teal",
"green": "Green",
"light-green": "Light Green",
"light-green": "Light green",
"lime": "Lime",
"yellow": "Yellow",
"amber": "Amber",
@@ -1045,7 +1000,7 @@
"podcast": "Podcast",
"season": "Season",
"track": "Track",
"tv_show": "TV Show",
"tv_show": "TV show",
"url": "URL",
"video": "Video"
},
@@ -2935,7 +2890,7 @@
"name": "Name",
"description": "Description",
"tag_id": "Tag ID",
"tag_id_placeholder": "Autogenerated if left empty",
"tag_id_placeholder": "Auto-generated if left empty",
"delete": "Delete",
"update": "Update",
"create": "Create",
@@ -4490,14 +4445,14 @@
"trace_no_longer_available": "Chosen trace is no longer available",
"enter_downloaded_trace": "Enter downloaded trace",
"tabs": {
"details": "Step Details",
"timeline": "Trace Timeline",
"details": "Step details",
"timeline": "Trace timeline",
"logbook": "Related logbook entries",
"automation_config": "Automation Config",
"step_config": "Step Config",
"changed_variables": "Changed Variables",
"blueprint_config": "Blueprint Config",
"script_config": "Script Config"
"automation_config": "Automation config",
"step_config": "Step config",
"changed_variables": "Changed variables",
"blueprint_config": "Blueprint config",
"script_config": "Script config"
},
"path": {
"choose": "Select a step on the left for more information.",
@@ -4544,7 +4499,7 @@
"caption": "Blueprints",
"description": "Manage blueprints",
"overview": {
"header": "Blueprint Editor",
"header": "Blueprint editor",
"introduction": "The blueprint configuration allows you to import and manage your blueprints.",
"learn_more": "Learn more about using blueprints",
"headers": {
@@ -4601,14 +4556,14 @@
"override_description": "Importing it will override the existing blueprint. If the updated blueprint is not compatible, it can break your automations. Automations will have to be adjusted manually.",
"error_no_url": "Please enter the blueprint address.",
"unsupported_blueprint": "This blueprint is not supported",
"file_name": "Blueprint Path"
"file_name": "Blueprint path"
}
},
"script": {
"caption": "Scripts",
"description": "Execute a sequence of actions",
"picker": {
"header": "Script Editor",
"header": "Script editor",
"introduction": "The script editor allows you to create and edit scripts. Please follow the link below to read the instructions to make sure that you have configured Home Assistant correctly.",
"learn_more": "Learn more about scripts",
"no_scripts": "We couldn't find any scripts",
@@ -4684,7 +4639,7 @@
"field_delete_confirm_title": "Delete field?",
"field_delete_confirm_text": "[%key:ui::panel::config::automation::editor::triggers::delete_confirm_text%]",
"header": "Script: {name}",
"default_name": "New Script",
"default_name": "New script",
"modes": {
"label": "[%key:ui::panel::config::automation::editor::modes::label%]",
"learn_more": "[%key:ui::panel::config::automation::editor::modes::learn_more%]",
@@ -4729,7 +4684,7 @@
"description": "Capture device states and easily recall them later",
"activated": "Activated scene {name}.",
"picker": {
"header": "Scene Editor",
"header": "Scene editor",
"introduction": "The scene editor allows you to create and edit scenes. Please follow the link below to read the instructions to make sure that you have configured Home Assistant correctly.",
"learn_more": "Learn more about scenes",
"pick_scene": "Pick scene to edit",
@@ -4993,7 +4948,7 @@
"other_home_assistant": "Other Home Assistant",
"instance_name": "Instance name",
"instance_version": "Instance version",
"ip_address": "IP Address",
"ip_address": "IP address",
"connected_at": "Connected at",
"obfuscated_ip": {
"show": "Show IP address",
@@ -5129,7 +5084,7 @@
"hidden": "Hidden"
},
"confirm_disable_config_entry_title": "Disable config entry?",
"confirm_disable_config_entry_message": "There are no more devices for the config entry {name}, do you want to instead disable the config entry?",
"confirm_disable_config_entry_message": "There are no more devices for the config entry {name}, do you want to disable the config entry instead?",
"update_device_error": "Updating the device failed",
"disabled": "Disabled",
"data_table": {
@@ -5138,6 +5093,7 @@
"manufacturer": "Manufacturer",
"model": "Model",
"area": "Area",
"floor": "Floor",
"integration": "Integration",
"battery": "Battery",
"disabled_by": "Disabled",
@@ -5381,8 +5337,9 @@
"enable": "Enable device",
"disable": "Disable device",
"confirm_disable_title": "Disable device?",
"confirm_disable_message": "Are you sure you want to disable {name} and all of its entities?",
"confirm_disable_message": "Are you sure you want to disable {name} and all of its entities?",
"configure": "Configure device",
"edit": "Edit device",
"delete": "Remove device"
},
"devices": "{count} {count, plural,\n one {device}\n other {devices}\n}",
@@ -5416,7 +5373,7 @@
"via": "Connected via",
"firmware": "Firmware: {version}",
"hardware": "Hardware: {version}",
"version": "Version: {version}",
"version": "Version {version}",
"serial_number": "Serial number: {serial_number}",
"unnamed_entry": "Unnamed entry",
"unknown_via_device": "Unknown device",
@@ -5661,16 +5618,16 @@
},
"dhcp": {
"title": "DHCP discovery",
"mac_address": "MAC Address",
"mac_address": "MAC address",
"hostname": "Hostname",
"ip_address": "IP Address",
"ip_address": "IP address",
"no_devices_found": "No recent DHCP requests found; no matching discoveries detected"
},
"thread": {
"other_networks": "Other networks",
"my_network": "Preferred network",
"no_preferred_network": "You don't have a preferred network yet.",
"more_info": "More Info",
"more_info": "More information",
"add_open_thread_border_router": "Add an OpenThread border router",
"reset_border_router": "Reset border router",
"add_to_my_network": "Add to preferred network",
@@ -5722,7 +5679,7 @@
"name": "Name",
"type": "Type",
"port": "Port",
"ip_addresses": "IP Addresses",
"ip_addresses": "IP addresses",
"properties": "Properties",
"discovery_information": "Discovery information",
"copy_to_clipboard": "Copy to clipboard",
@@ -5889,7 +5846,7 @@
"not_ready": "{count} not ready",
"nvm_backup": {
"title": "Backup and restore",
"description": "Back up or restore your Z-Wave controller's non-volatile memory (NVM). The NVM contains your network information including paired devices. It's recommended to create a backup before making any major changes to your Z-Wave network.",
"description": "Back up or restore your Z-Wave adapter's non-volatile memory (NVM). The NVM contains your network information including paired devices. It's recommended to create a backup before making any major changes to your Z-Wave network.",
"download_backup": "Download backup",
"restore_backup": "Restore from backup",
"backup_failed": "Failed to download backup",
@@ -5897,21 +5854,21 @@
"restore_failed": "Failed to restore backup",
"creating": "Creating backup",
"restoring": "Restoring backup",
"migrate": "Migrate controller"
"migrate": "Migrate adapter"
},
"statistics": {
"title": "Controller statistics",
"title": "Adapter statistics",
"messages_tx": {
"label": "Messages TX",
"tooltip": "Number of messages successfully sent to the controller"
"tooltip": "Number of messages successfully sent to the adapter"
},
"messages_rx": {
"label": "Messages RX",
"tooltip": "Number of messages successfully received by the controller"
"tooltip": "Number of messages successfully received by the adapter"
},
"messages_dropped_tx": {
"label": "Dropped messages TX",
"tooltip": "Number of messages from the controller that were dropped by the host"
"tooltip": "Number of messages from the adapter that were dropped by the host"
},
"messages_dropped_rx": {
"label": "Dropped messages RX",
@@ -5919,23 +5876,23 @@
},
"nak": {
"label": "NAK",
"tooltip": "Number of messages that the controller did not accept"
"tooltip": "Number of messages that the adapter did not accept"
},
"can": {
"label": "CAN",
"tooltip": "Number of collisions while sending a message to the controller"
"tooltip": "Number of collisions while sending a message to the adapter"
},
"timeout_ack": {
"label": "Timeout ACK",
"tooltip": "Number of transmission attempts where an ACK was missing from the controller"
"tooltip": "Number of transmission attempts where an ACK was missing from the adapter"
},
"timeout_response": {
"label": "Timeout response",
"tooltip": "Number of transmission attempts where the controller response did not come in time"
"tooltip": "Number of transmission attempts where the adapter response did not come in time"
},
"timeout_callback": {
"label": "Timeout callback",
"tooltip": "Number of transmission attempts where the controller callback did not come in time"
"tooltip": "Number of transmission attempts where the adapter callback did not come in time"
}
}
},
@@ -5958,18 +5915,18 @@
},
"hard_reset_controller": {
"NotStarted": {
"title": "Reset controller to factory settings",
"body": "If you decide to move forward, you will reset your controller to factory settings. As a result, the controller will forget all devices it is paired with and all Z-Wave devices for this network will be removed from Home Assistant. If there are any devices still paired with the controller when it is reset, they will have to go through the exclusion process before they can be re-paired. Would you like to continue?"
"title": "Reset adapter to factory settings",
"body": "If you decide to move forward, you will reset your adapter to factory settings. As a result, the adapter will forget all devices it is paired with and all Z-Wave devices for this network will be removed from Home Assistant. If there are any devices still paired with the adapter when it is reset, they will have to go through the exclusion process before they can be re-paired. Would you like to continue?"
},
"InProgress": {
"title": "Resetting controller",
"body": "Your controller is being reset and restarted. Wait until the process is complete before closing this dialog"
"title": "Resetting adapter",
"body": "Your adapter is being reset and restarted. Wait until the process is complete before closing this dialog"
},
"Done": {
"title": "Controller reset complete",
"body": "Your controller has been reset to factory settings and has been restarted! You can now close this dialog."
"title": "Adapter reset complete",
"body": "Your adapter has been reset to factory settings and has been restarted! You can now close this dialog."
},
"confirmation": "This action cannot be undone unless you have an NVM backup from your controller."
"confirmation": "This action cannot be undone unless you have a backup from your adapter."
},
"node_statistics": {
"title": "Device statistics",
@@ -6034,7 +5991,7 @@
},
"rssi": {
"label": "RSSI",
"tooltip": "The RSSI of the ACK frame received by the controller"
"tooltip": "The RSSI of the ACK frame received by the adapter"
},
"route_failed_between": {
"label": "Route failed between",
@@ -6253,7 +6210,7 @@
},
"rebuild_node_routes": {
"title": "Rebuild routes for a Z-Wave device",
"introduction": "Tell {device} to update its routes back to the controller. This can help with communication issues if you have recently moved the device or your controller.",
"introduction": "Assign new routes between {device} and the adapter. This can help with communication issues if you have recently moved the device or your adapter.",
"traffic_warning": "The route rebuilding process generates a large amount of traffic on the Z-Wave network. This may cause devices to respond slowly (or not at all) while the rebuilding is in progress.",
"start_rebuilding_routes": "Rebuild Routes for Device",
"rebuilding_routes_failed": "{device} routes could not be rebuild.",
@@ -6265,7 +6222,7 @@
"update_firmware": {
"title": "Update device firmware",
"warning": "WARNING: Firmware updates can brick your device if you do not correctly follow the manufacturer's guidance. The Home Assistant and Z-Wave JS teams do not take any responsibility for any damages to your device as a result of the firmware update and will not be able to help you if you brick your device. Would you still like to continue?",
"warning_controller": "WARNING: Firmware updates can brick your controller if you do not use the right firmware files, or if you attempt to stop the firmware update before it completes. The Home Assistant and Z-Wave JS teams do not take any responsibility for any damages to your controller as a result of the firmware update and will not be able to help you if you brick your controller. Would you still like to continue?",
"warning_controller": "WARNING: Firmware updates can brick your adapter if you do not use the right firmware files, or if you attempt to stop the firmware update before it completes. The Home Assistant and Z-Wave JS teams do not take any responsibility for any damages to your adapter as a result of the firmware update and will not be able to help you if you brick your adapter. Would you still like to continue?",
"introduction": "Select the firmware file you would like to use to update {device}.",
"introduction_controller": "Select the firmware file you would like to use to update {device}. Note that once you start a firmware update, you MUST wait for the update to complete.",
"firmware_target_intro": "Select the firmware target (0 for the Z-Wave chip, ≥1 for other chips if they exist) for this update.",
@@ -6286,7 +6243,7 @@
"error": "Unable to update firmware on {device}: {message}.",
"try_again": "To attempt the firmware update again, select the new firmware file you would like to use.",
"done": "The firmware update is complete! If you want to attempt another firmware update on this device, please wait until it gets re-interviewed.",
"done_controller": "The firmware update is complete! Your controller is being restarted and your network will temporarily be unavailable.",
"done_controller": "The firmware update is complete! Your adapter is being restarted and your network will temporarily be unavailable.",
"Error_Timeout": "Timed out",
"Error_Checksum": "Checksum error",
"Error_TransmissionFailed": "Transmission failed",
@@ -6354,7 +6311,7 @@
"title": "Door lock",
"twist_assist": "Twist assist",
"block_to_block": "Block to block",
"auto_relock_time": "Auto relock time",
"auto_relock_time": "Autorelock time",
"hold_release_time": "Hold and release time",
"operation_type": "Operation type",
"operation_types": {
@@ -6525,13 +6482,13 @@
"prefix": "Subnet prefix",
"add_address": "Add address",
"gateway": "Gateway address",
"dns_server": "DNS Server",
"add_dns_server": "Add DNS Server",
"dns_server": "DNS server",
"add_dns_server": "Add DNS server",
"custom_dns": "Custom",
"unsaved": "You have unsaved changes, these will get lost if you change tabs, do you want to continue?",
"failed_to_change": "Failed to change network settings",
"hostname": {
"title": "Host name",
"title": "Hostname",
"description": "The name your instance will have on your network",
"failed_to_set_hostname": "Setting hostname failed"
}
@@ -6548,9 +6505,9 @@
},
"network_adapter": "Network adapter",
"network_adapter_info": "Configure which network adapters integrations will use. A restart is required for these settings to apply.",
"ip_information": "IP Information",
"ip_information": "IP information",
"adapter": {
"auto_configure": "Auto configure",
"auto_configure": "Autoconfigure",
"detected": "Detected",
"adapter": "Adapter"
}
@@ -6559,7 +6516,7 @@
"caption": "Storage",
"description": "{percent_used} used - {free_space} free",
"used_space": "Used space",
"emmc_lifetime_used": "eMMC Lifetime Used",
"emmc_lifetime_used": "eMMC lifetime used",
"disk_metrics": "Disk metrics",
"datadisk": {
"title": "Move data disk",
@@ -6673,8 +6630,8 @@
},
"areas": {
"no_entities": "No entities in this area.",
"header": "Area badges",
"header_description": "To display temperature and humidity sensors in the overview and in the area view, add a sensor to that area and {edit_the_area} to configure related sensors.",
"sensors": "Sensors",
"sensors_description": "To display temperature and humidity sensors in the overview and in the area view, add a sensor to that area and {edit_the_area} to configure related sensors.",
"edit_the_area": "edit the area",
"groups": {
"lights": "Lights",
@@ -6685,7 +6642,8 @@
"actions": "Actions",
"others": "Others"
},
"unassigned_areas": "[%key:ui::panel::config::areas::picker::unassigned_areas%]"
"other_areas": "Other areas",
"areas": "Areas"
}
},
"cards": {
@@ -6909,7 +6867,7 @@
},
"edit_view": {
"header": "View configuration",
"header_name": "{name} View Configuration",
"header_name": "{name} view configuration",
"add": "Add view",
"background": {
"settings": "Background settings",
@@ -7025,7 +6983,7 @@
},
"edit_card": {
"header": "Card configuration",
"typed_header": "{type} Card configuration",
"typed_header": "{type} card configuration",
"pick_card": "Add to dashboard",
"pick_card_title": "Which card would you like to add to {name}",
"toggle_editor": "Toggle editor",
@@ -7095,7 +7053,7 @@
"move_card": {
"header": "Choose a view to move the card to",
"strategy_error_title": "Impossible to move the card",
"strategy_error_text_strategy": "Moving a card to an auto generated view is not supported.",
"strategy_error_text_strategy": "Moving a card to an auto-generated view is not supported.",
"success": "Card moved successfully",
"error": "Error while moving card"
},
@@ -7430,7 +7388,7 @@
},
"light": {
"name": "Light",
"description": "The Light card allows you to change the brightness of the light."
"description": "The Light card allows you to change the brightness of a light."
},
"generic": {
"alt_text": "Alternative text",
@@ -7518,13 +7476,13 @@
"geo_location_sources": "Geolocation sources",
"no_geo_location_sources": "No geolocation sources available",
"appearance": "Appearance",
"theme_mode": "Theme Mode",
"theme_mode": "Theme mode",
"theme_modes": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
},
"default_zoom": "Default Zoom",
"default_zoom": "Default zoom",
"source": "Source",
"description": "The Map card that allows you to display entities on a map."
},
@@ -7572,7 +7530,7 @@
"picture-elements": {
"name": "Picture elements",
"description": "The Picture elements card is one of the most versatile types of cards. The cards allow you to position icons or text and even actions! On an image based on coordinates.",
"card_options": "Card Options",
"card_options": "Card options",
"elements": "Elements",
"new_element": "Add new element",
"confirm_delete_element": "Are you sure you want to delete the {type} element?",
@@ -7611,7 +7569,7 @@
"none": "None",
"line": "Line"
},
"description": "The Sensor card gives you a quick overview of your sensors state with an optional graph to visualize change over time.",
"description": "The Sensor card gives you a quick overview of a sensor's state with an optional graph to visualize change over time.",
"limit_min": "Minimum value",
"limit_max": "Maximum value"
},
@@ -7621,14 +7579,14 @@
"integration_not_loaded": "This card requires the `todo` integration to be set up.",
"hide_completed": "Hide completed items",
"hide_create": "Hide 'Add item' field",
"display_order": "Display Order",
"display_order": "Display order",
"sort_modes": {
"none": "Default",
"manual": "Manual",
"alpha_asc": "Alphabetical (A-Z)",
"alpha_desc": "Alphabetical (Z-A)",
"duedate_asc": "Due Date (Soonest First)",
"duedate_desc": "Due Date (Latest First)"
"duedate_asc": "Due date (Soonest first)",
"duedate_desc": "Due date (Latest first)"
}
},
"thermostat": {
@@ -7638,7 +7596,7 @@
},
"tile": {
"name": "Tile",
"description": "The tile card gives you a quick overview of your entity. The card allows you to toggle the entity, show the More info dialog or trigger custom actions.",
"description": "The Tile card gives you a quick overview of an entity. The card allows you to toggle the entity, show the More info dialog or trigger custom actions.",
"color": "Color",
"color_helper": "Inactive state (e.g. off, closed) will not be colored.",
"icon_tap_action": "Icon tap behavior",
@@ -7692,7 +7650,7 @@
"badge": {
"entity": {
"name": "Entity",
"description": "The Entity badge gives you a quick overview of your entity.",
"description": "The Entity badge gives you a quick overview of an entity.",
"color": "[%key:ui::panel::lovelace::editor::card::tile::color%]",
"color_helper": "[%key:ui::panel::lovelace::editor::card::tile::color_helper%]",
"show_entity_picture": "Show entity picture",
@@ -8230,14 +8188,14 @@
"confirm_delete_title": "Delete long-lived access token?",
"confirm_delete_text": "Are you sure you want to delete the long-lived access token for {name}?",
"delete_failed": "Failed to delete the access token.",
"create": "Create Token",
"create": "Create token",
"create_failed": "Failed to create the access token.",
"name": "Name",
"prompt_name": "Give the token a name",
"prompt_copy_token": "Copy your access token. It will not be shown again.",
"empty_state": "You have no long-lived access tokens yet.",
"qr_code_image": "QR code for token {name}",
"generate_qr_code": "Generate QR Code"
"generate_qr_code": "Generate QR code"
}
},
"todo": {
@@ -8394,7 +8352,7 @@
"hdmi_input": "HDMI input",
"hdmi_switcher": "HDMI switcher",
"volume": "Volume",
"total_tv_time": "Total TV Time",
"total_tv_time": "Total TV time",
"turn_tv_off": "Turn television off",
"air": "Air"
},
@@ -8508,7 +8466,7 @@
"filter_states": "Filter states",
"filter_attributes": "Filter attributes",
"no_entities": "No entities",
"more_info": "More Info",
"more_info": "More info",
"alert_entity_field": "Entity is a mandatory field",
"last_updated": "[%key:ui::dialogs::more_info_control::last_updated%]",
"last_changed": "[%key:ui::dialogs::more_info_control::last_changed%]",
@@ -8664,11 +8622,11 @@
"input_button": "Input buttons",
"input_text": "Input texts",
"input_number": "Input numbers",
"input_datetime": "Input date times",
"input_datetime": "Input datetimes",
"input_select": "Input selects",
"template": "Template entities",
"universal": "Universal media player entities",
"rest": "Rest entities and notify services",
"rest": "REST entities and notify services",
"command_line": "Command line entities",
"filter": "Filter entities",
"statistics": "Statistics entities",
@@ -9075,7 +9033,7 @@
},
"capability": {
"stage": {
"title": "Add-on Stage",
"title": "Add-on stage",
"description": "Add-ons can have one of three stages:\n\n{icon_stable} **Stable**: These are add-ons ready to be used in production.\n\n{icon_experimental} **Experimental**: These may contain bugs, and may be unfinished.\n\n{icon_deprecated} **Deprecated**: These add-ons will no longer receive any updates."
},
"rating": {
@@ -9104,7 +9062,7 @@
},
"host_pid": {
"title": "Host processes namespace",
"description": "Usually, the processes the add-on runs, are isolated from all other system processes. The add-on author has requested the add-on to have access to the system processes running on the host system instance, and allow the add-on to spawn processes on the host system as well. This mode gives the add-on full access and control to your entire Home Assistant system, which adds security risks, and could damage your system when misused. Therefore, this feature impacts the add-on security score negatively.\n\nThis level of access is not granted automatically and needs to be confirmed by you. To do this, you need to disable the protection mode on the add-on manually. Only disable the protection mode if you know, need AND trust the source of this add-on."
"description": "Usually, the processes run by the add-on are isolated from all other system processes. The add-on author has requested the add-on to have access to the system processes running on the host system instance, and allow the add-on to spawn processes on the host system as well. This mode gives the add-on full access and control to your entire Home Assistant system, which adds security risks and could damage your system when misused. Therefore, this feature impacts the add-on security score negatively.\n\nThis level of access is not granted automatically and needs to be confirmed by you. To do this, you need to disable the protection mode on the add-on manually. Only disable the protection mode if you know, need AND trust the source of this add-on."
},
"apparmor": {
"title": "AppArmor",
@@ -9157,8 +9115,8 @@
"description": "This will restart the add-on if it crashes"
},
"auto_update": {
"title": "Auto update",
"description": "Auto update the add-on when there is a new version available"
"title": "Autoupdate",
"description": "Autoupdate the add-on when there is a new version available"
},
"ingress_panel": {
"title": "Show in sidebar",
@@ -9269,7 +9227,7 @@
"addons": "Add-ons",
"dashboard": "Dashboard",
"backups": "Backups",
"store": "Add-on Store",
"store": "Add-on store",
"system": "System"
},
"my": {
@@ -9359,7 +9317,7 @@
"hostname": "Hostname",
"change_hostname": "Change hostname",
"new_hostname": "Please enter a new hostname:",
"ip_address": "IP Address",
"ip_address": "IP address",
"change": "Change",
"operating_system": "Operating system",
"docker_version": "Docker version",
@@ -9411,7 +9369,7 @@
"confirm_password": "Confirm encryption key",
"password_protection": "Password protection",
"enter_password": "Please enter a password.",
"passwords_not_matching": "The passwords does not match",
"passwords_not_matching": "The passwords do not match",
"backup_already_running": "A backup or restore is already running. Creating a new backup is currently not possible, try again later.",
"confirm_restore_partial_backup_title": "Restore partial backup",
"confirm_restore_partial_backup_text": "The backup will be restored. Depending on the size of the backup, this can take up to 45 min. Home Assistant needs to shutdown and the restore progress is running in the background. If it succeeds, Home Assistant will automatically start again.",

View File

@@ -0,0 +1,51 @@
import { assert, describe, it } from "vitest";
import { stringCompare } from "../../../src/common/string/compare";
describe("stringCompare", () => {
// Node only ships with English support for `Intl`, so we cannot test for other language collators.
it("Ensure natural order reutrned when numeric value is included", () => {
assert.strictEqual(stringCompare("Helper 2", "Helper 10"), -1);
});
it("Ensure prefixed numeric value is sorted naturally", () => {
assert.strictEqual(stringCompare("2 Helper", "10 Helper"), -1);
});
it("Ensure order has reversed alphabet is sorted", () => {
const reverseAlphabet = [
"z",
"y",
"x",
"w",
"v",
"u",
"t",
"d",
"c",
"b",
"a",
];
assert.deepStrictEqual(
[...reverseAlphabet].sort(stringCompare),
[...reverseAlphabet].reverse()
);
});
it("Ensure natural order when using numbers", () => {
const testArray = [
"Helper 1",
"Helper 10",
"Helper 2",
"Helper 3",
"Helper 4",
];
assert.deepStrictEqual([...testArray].sort(stringCompare), [
"Helper 1",
"Helper 2",
"Helper 3",
"Helper 4",
"Helper 10",
]);
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import {
addSearchParam,
createSearchParam,
extractSearchParam,
extractSearchParamsObject,
removeSearchParam,
} from "../../../src/common/url/search-params";
const sortQueryString = (querystring: string): string =>
querystring.split("&").sort().join("&");
vi.mock("../../../src/common/dom/get_main_window", () => ({
mainWindow: { location: { search: "?param1=ab+c&param2" } },
}));
afterEach(() => {
vi.resetAllMocks();
});
describe("Search Params Tests", () => {
it("should extract all search params from window object", () => {
expect(extractSearchParamsObject()).toEqual({ param1: "ab c", param2: "" });
});
it("should return value for specified search param from window object", () => {
expect(extractSearchParam("param1")).toEqual("ab c");
});
it("should create query string from given object", () => {
expect(
sortQueryString(createSearchParam({ param1: "ab c", param2: "" }))
).toEqual(sortQueryString("param1=ab+c&param2="));
});
it("should return query string which combines provided param object and window.location.search", () => {
expect(
sortQueryString(addSearchParam({ param4: "", param3: "x y" }))
).toEqual(sortQueryString("param1=ab+c&param2=&param3=x+y&param4="));
});
it("should return query string from window.location.search but remove the provided param from it", () => {
expect(sortQueryString(removeSearchParam("param2"))).toEqual(
sortQueryString("param1=ab+c")
);
});
});

View File

@@ -70,8 +70,10 @@ describe("Energy Short Format Test", () => {
const hass = { locale: defaultLocale } as HomeAssistant;
it("No Unit conversion", () => {
assert.strictEqual(formatConsumptionShort(hass, 0, "Wh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh"), "0 kWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh"), "0 GWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh", "kWh"), "0 kWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh", "GWh"), "0 GWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "gal"), "0 gal");
assert.strictEqual(
@@ -139,6 +141,36 @@ describe("Energy Short Format Test", () => {
"-1.23 Wh"
);
});
it("Conversion with target unit", () => {
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "Wh"),
"0.12 Wh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "kWh"),
"0 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.01012, "kWh", "kWh"),
"0.01 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "MWh"),
"0 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "kWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "ZZZZZWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 151234.5678, "kWh", "MWh"),
"151 MWh"
);
});
});
describe("Energy Usage Calculation Tests", () => {

1308
yarn.lock

File diff suppressed because it is too large Load Diff