Compare commits

..

40 Commits

Author SHA1 Message Date
Simon Lamon
4b3fb17c89 Remove open from ha-dropdown 2025-11-21 20:30:28 +00:00
Petar Petrov
be319503f7 Update color scheme in ZHA network visualization (#28032) 2025-11-21 19:09:37 +01:00
Paul Bottein
9b1fe28018 Rename defaultPanel to default_panel (#28035) 2025-11-21 16:24:34 +01:00
Paul Bottein
0595f722f3 Add basic editor to edit favorites entities for home panel (#28028)
* Add basic editor to edit favorites entities for home panel

* Rename favorites

* Rename favorites

* Feedbacks
2025-11-21 16:19:39 +02:00
Petar Petrov
1c0315854a Hide echarts toolbox better (#28030) 2025-11-21 14:52:20 +02:00
Bram Kragten
3b73d7c298 Dont add store token for external auth flows (#28026)
* Dont add store token for external auth flows

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 12:01:02 +00:00
Aidan Timson
2955cb4956 Make use of documentationUrl over hardcoded docs links (#28022)
Make use of documentationUrl over hardcoded docs link
2025-11-21 13:43:46 +02:00
Franck Nijhof
c679e312a0 Add delete option to reauth cards on integrations dashboard (#28020)
* Add delete option to reauth cards on integrations dashboard

Users can now delete config entries directly from the reauth card that appears at the top of the integrations dashboard, instead of having to scroll down to find the original integration card.

The delete option:
- Appears in the three-dot menu on reauth cards
- Shows a confirmation dialog before deletion
- Handles application credentials cleanup
- Shows restart notifications when required
- Uses the same styling and localization as the integration entry delete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 13:39:01 +02:00
Aidan Timson
37e12a83be Fix scene review switch action overflow issue (#28024)
* Fix scene review switch action overflow issue

* Be more specific
2025-11-21 10:33:49 +00:00
Aidan Timson
755c6dbb93 Add design tokens to labs feature (#28023) 2025-11-21 11:21:33 +01:00
Silas Krause
4a90331ac7 Add markdown support for assist messages (#27957)
* Add markdown support for assist messages

* Improve styles

* Refactor code

* Fix white space

* Move code

* Make css compiler happy

* Wait for render to complete before scrolling

* Revert changes

* Refactor ha-markdown to render in chunks

* Refactor and adapt scroll logic

* Fix imports

* Update styles

* Render into renderRoot

* Fix query selector

* Fix broken image style

* Implement PR feedback

* Remove unnecessary css

* Fix cache issue

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 08:03:47 +00:00
Aidan Timson
7b264ae338 Standardise fixed top ha-dialog usages and fix safe areas (#27997)
* Add default padding

* Use vars

* Restore

* Use container padding

* Move fixed top styles to shared styles

* Use fixed styles and adjust for safe areas on media browse dialog

* Use vars

* Reduce

* Reuse

* Add to strategy editor dialog

* Reuse for editors

* Reuse for media manager

* Remove redundant code

* Reuse for quick bar

* Add

* Use vars

* Fix

* Fix

* Adjust for safe area

* Fix

* Fix

* Default

* Fix

* Extra space at bottom

* Remove override

* Remove

* Fix

* Fix

* Remove

* Fix

* Fix

* Use y inset

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Format

* Update src/components/media-player/dialog-media-player-browse.ts

* Format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 08:24:14 +02:00
Franck Nijhof
bb5fefce2b Introduce Home Assistant Labs (#27989)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-20 21:22:14 +01:00
Aidan Timson
5703de9616 Update LLM instructions to recent codebase changes (#28017)
* Update dialog instructions

* Add button styling and dialog sizing

* Document spacing tokens

* Format

* Explicit spacing token documentation

* View transitions

* Add vt notes

* Keyboard shortcuts

* Tooltip

* Link to the gallery for guidelines

* Add AGENTS.md symlink for newer tools

* Use more sane spacing tokens
2025-11-20 17:59:56 +00:00
Petar Petrov
eee2c1e8fd Add power graphs in energy view (#28010) 2025-11-20 18:45:04 +01:00
Petar Petrov
d4c1642ccc Fix network graph panning (#28015) 2025-11-20 18:37:33 +01:00
Aidan Timson
0d693e692a Update dialogs to use space tokens (#28018) 2025-11-20 18:35:08 +01:00
Aidan Timson
c6091971b6 Fix overflowing content issue in states view (#28009) 2025-11-20 12:32:32 +00:00
hanwg
60229ceba0 Add markdown to parameter descriptions for actions (#27944)
add markdown to parameter descriptions for actions
2025-11-20 08:39:27 +02:00
Paul Bottein
e45b631e27 Allow to reorder areas and floors (#27986)
* Add websocket commands

* Add area reordering

* Reorder floors

* Order areas and floors everywhere

* Use right area order in area floor picker

* Add error handling

* Refactor
2025-11-19 16:01:02 +01:00
Petar Petrov
ea798cda90 Bump glob to 12.0.0 (#27999) 2025-11-19 15:18:15 +01:00
Petar Petrov
259f4421db Add detail option to trend card feature (#27993) 2025-11-19 15:17:03 +01:00
Paul Bottein
1ac3cf199f Save default panel at user and system level (#27899)
* Save default panel in user data

* Change logic for default panel

* Fix types

* Fix typings

* Fix user and local storage

* Use user data and system data

* Update url path and update dashboard settings

* Fix tests

* Wait for panels and user/system data to be loaded

* Update comment

* Update comment

* Set empty object instead of null

* Update comment

* Feedbacks

* Apply suggestions from code review

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-19 15:03:20 +01:00
Aidan Timson
8d96679cc3 Swap to margins for narrow safe areas in ha-md-dialog (#27994)
Swap to margins for narrow layouts and safe areas
2025-11-19 15:56:10 +02:00
karwosts
ba9c7f3012 Expose location for calendar events (#27983)
* Expose location for calendar events

* from review
2025-11-19 15:40:19 +02:00
Petar Petrov
f8923ed648 Split Energy panel into overview and electricity view (#27534) 2025-11-19 14:11:27 +01:00
Petar Petrov
20d0548d33 Add min/max options to bar gauge feature (#27933) 2025-11-19 11:20:06 +01:00
Aidan Timson
04aaae20f5 Create keyboard shortcuts helper (tinykeys) (#27176) 2025-11-19 11:19:01 +01:00
karwosts
852dbbeee0 Fix keyboard for integration page overflow menus (#27991) 2025-11-19 08:44:51 +02:00
Ezra Freedman
d57367f62e Fix selection state not updating after item deletion (#27972)
* Fix selection state not updating after item deletion

* only update selected list if script is found
2025-11-19 08:20:12 +02:00
Petar Petrov
47a107dd85 Add Power Sankey card (#27966) 2025-11-18 19:52:52 +01:00
renovate[bot]
3573e823e4 Update Yarn to v4.11.0 (#27978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 17:05:36 +00:00
Ingolf Becker
1a8319a3ab Create mobile column gap variable in hui-sections-view (#27949)
Update mobile column gap variable in hui-sections-view
2025-11-18 14:56:28 +02:00
dependabot[bot]
ac23ce6300 Bump glob from 11.0.3 to 11.1.0 (#27976)
Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.3 to 11.1.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.3...v11.1.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 06:27:19 +01:00
karwosts
fc38365958 Fix keyboard for ha-date-input (#27968)
* Fix keyboard for ha-date-input

* Update src/components/ha-date-input.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 14:22:36 +00:00
koostamas
b93bf3bc4b Remote stream playing in picture-in-picture fix (#27958)
* Remote stream playing in picture-in-picture fix

* Update src/components/ha-hls-player.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Update src/components/ha-web-rtc-player.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 15:44:24 +02:00
Raad Altaie
869ab6ffc4 disable browser autofill on search inputs (#27963) 2025-11-17 12:58:40 +00:00
dependabot[bot]
effba9b918 Bump github/codeql-action from 4.31.2 to 4.31.3 (#27965) 2025-11-17 07:19:27 +01:00
Yosi Levy
c848673b1f Various RTL fixes (#27886) 2025-11-16 14:42:09 +02:00
renovate[bot]
074095d3dc Update dependency js-yaml to v4.1.1 [SECURITY] (#27955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 15:42:16 +02:00
129 changed files with 4753 additions and 1318 deletions

View File

@@ -2,6 +2,8 @@
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. 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.
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
## Table of Contents ## Table of Contents
- [Quick Reference](#quick-reference) - [Quick Reference](#quick-reference)
@@ -151,6 +153,10 @@ try {
### Styling Guidelines ### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system - **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-0` (0px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop - **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate - **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages - **Support RTL**: Ensure all layouts work in RTL languages
@@ -159,21 +165,68 @@ try {
static get styles() { static get styles() {
return css` return css`
:host { :host {
--spacing: 16px; padding: var(--ha-space-4);
padding: var(--spacing);
color: var(--primary-text-color); color: var(--primary-text-color);
background-color: var(--card-background-color); background-color: var(--card-background-color);
} }
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) { @media (max-width: 600px) {
:host { :host {
--spacing: 8px; padding: var(--ha-space-2);
} }
} }
`; `;
} }
``` ```
### View Transitions
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
**Core Resources:**
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
**Default Root Transition:**
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
**Important Constraints:**
- Each `view-transition-name` must be unique at any given time
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Current Usage & Planned Applications:**
- Launch screen fade out (implemented)
- Automation sidebar transitions (planned - #27238)
- More info dialog content changes (planned - #27672)
- Toolbar navigation, ha-spinner transitions (planned)
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
### Performance Best Practices ### Performance Best Practices
- **Code split**: Split code at the panel/dialog level - **Code split**: Split code at the panel/dialog level
@@ -195,8 +248,9 @@ static get styles() {
**Available Dialog Types:** **Available Dialog Types:**
- `ha-md-dialog` - Preferred for new code (Material Design 3) - `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
- `ha-dialog` - Legacy component still widely used - `ha-md-dialog` - Material Design 3 dialog component
- `ha-dialog` - Legacy component (still widely used)
**Opening Dialogs (Fire Event Pattern - Recommended):** **Opening Dialogs (Fire Event Pattern - Recommended):**
@@ -211,15 +265,45 @@ fireEvent(this, "show-dialog", {
**Dialog Implementation Requirements:** **Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface - Implement `HassDialog<T>` interface
- Use `createCloseHeading()` for standard headers - Use `@state() private _open = false` to control dialog visibility
- Import `haStyleDialog` for consistent styling - Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
- Return `nothing` when no params (loading state) - Return `nothing` when no params (loading state)
- Fire `dialog-closed` event when closing - Fire `dialog-closed` event in `_dialogClosed()` handler
- Add `dialogInitialFocus` for accessibility - Use `header-title` attribute for simple titles
- Use `header-subtitle` attribute for simple subtitles
- Use slots for custom content where the standard attributes are not enough
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
```` **Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
**Button Appearance Guidelines:**
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
**Recent Examples:**
See these files for current patterns:
- `src/panels/config/repairs/dialog-repairs-issue.ts`
- `src/dialogs/restart/dialog-restart.ts`
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
**Gallery Documentation:**
- `gallery/src/pages/components/ha-wa-dialog.markdown`
- `gallery/src/pages/components/ha-dialogs.markdown`
### Form Component (ha-form) ### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]` - Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors - Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display - Built-in validation with error display
@@ -235,7 +319,11 @@ fireEvent(this, "show-dialog", {
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)} .computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
```` ```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert) ### Alert Component (ha-alert)
@@ -249,6 +337,35 @@ fireEvent(this, "show-dialog", {
<ha-alert alert-type="success" dismissable>Success message</ha-alert> <ha-alert alert-type="success" dismissable>Success message</ha-alert>
``` ```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-alert.markdown`
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
**Key Features:**
- Automatically blocks shortcuts when input fields are focused
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
**Implementation:**
- **Class definition**: `src/common/keyboard/shortcuts.ts`
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
### Tooltip Component (ha-tooltip)
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
**Implementation:**
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
## Common Patterns ## Common Patterns
### Creating a Panel ### Creating a Panel
@@ -289,11 +406,19 @@ export class DialogMyFeature
@state() @state()
private _params?: MyDialogParams; private _params?: MyDialogParams;
@state()
private _open = false;
public async showDialog(params: MyDialogParams): Promise<void> { public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params; this._params = params;
this._open = true;
} }
public closeDialog(): void { public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@@ -304,23 +429,27 @@ export class DialogMyFeature
} }
return html` return html`
<ha-dialog <ha-wa-dialog
open .hass=${this.hass}
@closed=${this.closeDialog} .open=${this._open}
.heading=${createCloseHeading(this.hass, this._params.title)} header-title=${this._params.title}
header-subtitle=${this._params.subtitle}
@closed=${this._dialogClosed}
> >
<!-- Dialog content --> <p>Dialog content</p>
<ha-dialog-footer slot="footer">
<ha-button <ha-button
slot="secondaryAction"
appearance="plain" appearance="plain"
@click=${this.closeDialog} @click=${this.closeDialog}
slot="secondaryAction"
> >
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</ha-button> </ha-button>
<ha-button @click=${this._submit} slot="primaryAction"> <ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize("ui.common.save")} ${this.hass.localize("ui.common.save")}
</ha-button> </ha-button>
</ha-dialog> </ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs yarnPath: .yarn/releases/yarn-4.11.0.cjs

1
AGENTS.md Symbolic link
View File

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

View File

@@ -11,7 +11,7 @@ A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdo
### Example usage (composition) ### Example usage (composition)
```html ```html
<ha-dropdown open> <ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button> <ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item> <ha-dropdown-item>

View File

@@ -28,7 +28,7 @@ export class DemoHaDropdown extends LitElement {
<div class=${mode}> <div class=${mode}>
<ha-card header="ha-button in ${mode}"> <ha-card header="ha-button in ${mode}">
<div class="card-content"> <div class="card-content">
<ha-dropdown open> <ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button> <ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item> <ha-dropdown-item>

View File

@@ -115,7 +115,7 @@
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18", "intl-messageformat": "10.7.18",
"js-yaml": "4.1.0", "js-yaml": "4.1.1",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3", "leaflet.markercluster": "1.5.3",
@@ -194,7 +194,7 @@
"eslint-plugin-wc": "3.0.2", "eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"glob": "11.0.3", "glob": "12.0.0",
"gulp": "5.0.1", "gulp": "5.0.1",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
@@ -233,9 +233,10 @@
"@fullcalendar/daygrid": "6.1.19", "@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0", "globals": "16.5.0",
"tslib": "2.8.1", "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" "@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",
"glob@^10.2.2": "^10.5.0"
}, },
"packageManager": "yarn@4.10.3", "packageManager": "yarn@4.11.0",
"volta": { "volta": {
"node": "22.21.1" "node": "22.21.1"
} }

View File

@@ -59,7 +59,8 @@ export class HaAuthFlow extends LitElement {
willUpdate(changedProps: PropertyValues) { willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (!this.hasUpdated) { if (!this.hasUpdated && this.clientId === genClientId()) {
// Preselect store token when logging in to own instance
this._storeToken = this.initStoreToken; this._storeToken = this.initStoreToken;
} }

View File

@@ -0,0 +1,53 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getAreasFloorHierarchy = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): AreasFloorHierarchy => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const hierarchy: AreasFloorHierarchy = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return hierarchy;
};
export const getAreasOrder = (hierarchy: AreasFloorHierarchy): string[] => {
const order: string[] = [];
for (const floor of hierarchy.floors) {
order.push(...floor.areas);
}
order.push(...hierarchy.areas);
return order;
};
export const getFloorOrder = (hierarchy: AreasFloorHierarchy): string[] =>
hierarchy.floors.map((floor) => floor.id);

View File

@@ -0,0 +1,67 @@
import { tinykeys } from "tinykeys";
import { canOverrideAlphanumericInput } from "../dom/can-override-input";
/**
* A function to handle a keyboard shortcut.
*/
export type ShortcutHandler = (event: KeyboardEvent) => void;
/**
* Configuration for a keyboard shortcut.
*/
export interface ShortcutConfig {
handler: ShortcutHandler;
/**
* If true, allows shortcuts even when text is selected.
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
}
/**
* Register keyboard shortcuts using tinykeys.
* Automatically blocks shortcuts in input fields and during text selection.
*/
function registerShortcuts(
shortcuts: Record<string, ShortcutConfig>
): () => void {
const wrappedShortcuts: Record<string, ShortcutHandler> = {};
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
return;
}
config.handler(event);
};
});
return tinykeys(window, wrappedShortcuts);
}
/**
* Manages keyboard shortcuts registration and cleanup.
*/
export class ShortcutManager {
private _disposer?: () => void;
/**
* Register keyboard shortcuts.
* Uses tinykeys syntax: https://github.com/jamiebuilds/tinykeys#usage
*/
public add(shortcuts: Record<string, ShortcutConfig>) {
this._disposer?.();
this._disposer = registerShortcuts(shortcuts);
}
/**
* Remove all registered shortcuts.
*/
public remove() {
this._disposer?.();
this._disposer = undefined;
}
}

View File

@@ -597,10 +597,15 @@ export class HaChartBase extends LitElement {
aria: { show: true }, aria: { show: true },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
toolbox: { toolbox: {
top: Infinity, top: Number.MAX_SAFE_INTEGER,
left: Infinity, left: Number.MAX_SAFE_INTEGER,
feature: { feature: {
dataZoom: { show: true, yAxisIndex: false, filterMode: "none" }, dataZoom: {
show: true,
yAxisIndex: false,
filterMode: "none",
showTitle: false,
},
}, },
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },

View File

@@ -188,6 +188,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
layout: physicsEnabled ? "force" : "none", layout: physicsEnabled ? "force" : "none",
draggable: true, draggable: true,
roam: true, roam: true,
roamTrigger: "global",
selectedMode: "single", selectedMode: "single",
label: { label: {
show: showLabels, show: showLabels,

View File

@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
--_label-text-font: var(--ha-font-family-body);
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
} }
`, `,

View File

@@ -298,6 +298,18 @@ export class HaDataTable extends LitElement {
} }
if (properties.has("data")) { if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
if (validCheckedRows.length !== this._checkedRows.length) {
this._checkedRows = validCheckedRows;
this._checkedRowsChanged();
}
}
this._checkableRowsCount = this.data.filter( this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false (row) => row.selectable !== false
).length; ).length;

View File

@@ -17,6 +17,7 @@ import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder"; import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-alert"; import "./ha-alert";
import "./ha-markdown";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@@ -40,7 +41,11 @@ export class HaAssistChat extends LitElement {
@query("#message-input") private _messageInput!: HaTextField; @query("#message-input") private _messageInput!: HaTextField;
@query("#scroll-container") private _scrollContainer!: HTMLDivElement; @query(".message:last-child")
private _lastChatMessage!: LitElement;
@query(".message:last-child img:last-of-type")
private _lastChatMessageImage: HTMLImageElement | undefined;
@state() private _conversation: AssistMessage[] = []; @state() private _conversation: AssistMessage[] = [];
@@ -92,10 +97,7 @@ export class HaAssistChat extends LitElement {
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined;
this._unloadAudio(); this._unloadAudio();
this._conversation = [];
this._conversationId = null;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@@ -112,7 +114,7 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html` return html`
<div class="messages" id="scroll-container"> <div class="messages">
${controlHA ${controlHA
? nothing ? nothing
: html` : html`
@@ -124,10 +126,17 @@ export class HaAssistChat extends LitElement {
`} `}
<div class="spacer"></div> <div class="spacer"></div>
${this._conversation!.map( ${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html` (message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div> <ha-markdown
class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
breaks
cache
.content=${message.text}
>
</ha-markdown>
` `
)} )}
</div> </div>
@@ -189,12 +198,28 @@ export class HaAssistChat extends LitElement {
`; `;
} }
private _scrollMessagesBottom() { private async _scrollMessagesBottom() {
const scrollContainer = this._scrollContainer; const lastChatMessage = this._lastChatMessage;
if (!scrollContainer) { if (!lastChatMessage.hasUpdated) {
return; await lastChatMessage.updateComplete;
}
if (
this._lastChatMessageImage &&
!this._lastChatMessageImage.naturalHeight
) {
try {
await this._lastChatMessageImage.decode();
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn("Failed to decode image:", err);
}
}
const isLastMessageFullyVisible =
lastChatMessage.getBoundingClientRect().y <
this.getBoundingClientRect().top + 24;
if (!isLastMessageFullyVisible) {
lastChatMessage.scrollIntoView({ behavior: "smooth", block: "start" });
} }
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
} }
private _handleKeyUp(ev: KeyboardEvent) { private _handleKeyUp(ev: KeyboardEvent) {
@@ -586,42 +611,31 @@ export class HaAssistChat extends LitElement {
flex: 1; flex: 1;
} }
.message { .message {
white-space: pre-line;
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
clear: both; clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: 24px;
margin: 8px 0; margin: 8px 0;
padding: 8px; padding: 8px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
} }
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.message { .message {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
} }
} }
.message p {
margin: 0;
}
.message p:not(:last-child) {
margin-bottom: 8px;
}
.message.user { .message.user {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
align-self: flex-end; align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(--chat-background-color-user, var(--primary-color)); background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color); color: var(--text-primary-color);
direction: var(--direction); direction: var(--direction);
} }
.message.hass { .message.hass {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
@@ -636,20 +650,21 @@ export class HaAssistChat extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
direction: var(--direction); direction: var(--direction);
} }
.message.user a {
color: var(--text-primary-color);
}
.message.hass a {
color: var(--primary-text-color);
}
.message.error { .message.error {
background-color: var(--error-color); background-color: var(--error-color);
color: var(--text-primary-color); color: var(--text-primary-color);
} }
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
.bouncer { .bouncer {
width: 48px; width: 48px;
height: 48px; height: 48px;

View File

@@ -94,6 +94,12 @@ export class HaDateInput extends LitElement {
} }
private _keyDown(ev: KeyboardEvent) { private _keyDown(ev: KeyboardEvent) {
if (["Space", "Enter"].includes(ev.code)) {
ev.preventDefault();
ev.stopPropagation();
this._openDialog();
return;
}
if (!this.canClear) { if (!this.canClear) {
return; return;
} }

View File

@@ -90,7 +90,8 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__actions { .mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end); justify-content: var(--justify-action-buttons, flex-end);
padding: 12px 16px 16px 16px; padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
} }
.mdc-dialog__actions span:nth-child(1) { .mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset); flex: var(--secondary-action-button-flex, unset);
@@ -100,22 +101,24 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__container { .mdc-dialog__container {
align-items: var(--vertical-align-dialog, center); align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, var(--ha-space-0));
} }
.mdc-dialog__title { .mdc-dialog__title {
padding: 16px 16px 0 16px; padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-0)
var(--ha-space-4);
} }
.mdc-dialog__title:has(span) { .mdc-dialog__title:has(span) {
padding: 12px 12px 0; padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-0);
} }
.mdc-dialog__title::before { .mdc-dialog__title::before {
content: unset; content: unset;
} }
.mdc-dialog .mdc-dialog__content { .mdc-dialog .mdc-dialog__content {
position: var(--dialog-content-position, relative); position: var(--dialog-content-position, relative);
padding: var(--dialog-content-padding, 24px); padding: var(--dialog-content-padding, var(--ha-space-6));
} }
:host([hideactions]) .mdc-dialog .mdc-dialog__content { :host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: var(--dialog-content-padding, 24px); padding-bottom: var(--dialog-content-padding, var(--ha-space-6));
} }
.mdc-dialog .mdc-dialog__surface { .mdc-dialog .mdc-dialog__surface {
position: var(--dialog-surface-position, relative); position: var(--dialog-surface-position, relative);
@@ -133,7 +136,7 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
padding: var(--dialog-surface-padding); padding: var(--dialog-surface-padding, var(--ha-space-0));
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;
@@ -150,22 +153,22 @@ export class HaDialog extends DialogBase {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
display: block; display: block;
padding-left: 4px; padding-left: var(--ha-space-1);
padding-right: 4px; padding-right: var(--ha-space-1);
margin-right: 12px; margin-right: var(--ha-space-3);
margin-inline-end: 12px; margin-inline-end: var(--ha-space-3);
margin-inline-start: initial; margin-inline-start: initial;
} }
.header_button { .header_button {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
inset-inline-start: initial; inset-inline-start: initial;
inset-inline-end: -12px; inset-inline-end: calc(var(--ha-space-3) * -1);
direction: var(--direction); direction: var(--direction);
} }
.dialog-actions { .dialog-actions {
inset-inline-start: initial !important; inset-inline-start: initial !important;
inset-inline-end: 0px !important; inset-inline-end: var(--ha-space-0) !important;
direction: var(--direction); direction: var(--direction);
} }
`, `,

View File

@@ -60,6 +60,10 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0; private static streamCount = 0;
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -1,11 +1,15 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit"; import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash"; import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown"; import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager"; import { CacheManager } from "../util/cache-manager";
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
const markdownCache = new CacheManager<string>(1000); const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = { const _gitHubMarkdownAlerts = {
@@ -48,18 +52,26 @@ class HaMarkdownElement extends ReactiveElement {
return this; return this;
} }
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
protected update(changedProps) { protected update(changedProps) {
super.update(changedProps); super.update(changedProps);
if (this.content !== undefined) { if (this.content !== undefined) {
this._render(); this._renderPromise = this._render();
} }
} }
protected async getUpdateComplete(): Promise<boolean> {
await super.getUpdateComplete();
await this._renderPromise;
return true;
}
protected willUpdate(_changedProperties: PropertyValues): void { protected willUpdate(_changedProperties: PropertyValues): void {
if (!this.innerHTML && this.cache) { if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey(); const key = this._computeCacheKey();
if (markdownCache.has(key)) { if (markdownCache.has(key)) {
this.innerHTML = markdownCache.get(key)!; render(markdownCache.get(key)!, this.renderRoot);
this._resize(); this._resize();
} }
} }
@@ -75,7 +87,7 @@ class HaMarkdownElement extends ReactiveElement {
} }
private async _render() { private async _render() {
this.innerHTML = await renderMarkdown( const elements = await renderMarkdown(
String(this.content), String(this.content),
{ {
breaks: this.breaks, breaks: this.breaks,
@@ -87,6 +99,11 @@ class HaMarkdownElement extends ReactiveElement {
} }
); );
render(
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
this._resize(); this._resize();
const walker = document.createTreeWalker( const walker = document.createTreeWalker(

View File

@@ -1,5 +1,12 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; import {
import { customElement, property } from "lit/decorators"; css,
html,
LitElement,
nothing,
type ReactiveElement,
type CSSResultGroup,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import "./ha-markdown-element"; import "./ha-markdown-element";
@customElement("ha-markdown") @customElement("ha-markdown")
@@ -18,6 +25,14 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean }) public cache = false; @property({ type: Boolean }) public cache = false;
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
protected async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._markdownElement.updateComplete;
return result;
}
protected render() { protected render() {
if (!this.content) { if (!this.content) {
return nothing; return nothing;
@@ -53,19 +68,46 @@ export class HaMarkdown extends LitElement {
margin: var(--ha-space-1) 0; margin: var(--ha-space-1) 0;
} }
a { a {
color: var(--primary-color); color: var(--markdown-link-color, var(--primary-color));
} }
img { img {
background-color: rgba(10, 10, 10, 0.15);
border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
min-height: 2lh;
height: auto;
width: auto;
text-indent: 4px;
transition: height 0.2s ease-in-out;
}
p:first-child > img:first-child {
vertical-align: top;
}
p:first-child > img:last-child {
vertical-align: top;
}
ol,
ul {
list-style-position: inside;
padding-inline-start: 0;
}
li {
&:has(input[type="checkbox"]) {
list-style: none;
& > input[type="checkbox"] {
margin-left: 0;
}
}
}
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code, code,
pre { pre {
background-color: var(--markdown-code-background-color, none); background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm); border-radius: var(--ha-border-radius-sm);
} color: var(--markdown-code-text-color, inherit);
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code { code {
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
@@ -97,6 +139,24 @@ export class HaMarkdown extends LitElement {
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table {
border-collapse: collapse;
display: block;
overflow-x: auto;
}
th {
text-align: start;
}
td,
th {
border: 1px solid var(--markdown-table-border-color, transparent);
padding: 0.25em 0.5em;
}
blockquote {
border-left: 4px solid var(--divider-color);
margin-inline: 0;
padding-inline: 1em;
}
` as CSSResultGroup; ` as CSSResultGroup;
} }

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
} }
.container { .container {
padding-top: var(--safe-area-inset-top); margin-top: var(--safe-area-inset-top, var(--ha-space-0));
padding-bottom: var(--safe-area-inset-bottom); margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
padding-left: var(--safe-area-inset-left); margin-left: var(--safe-area-inset-left, var(--ha-space-0));
padding-right: var(--safe-area-inset-right); margin-right: var(--safe-area-inset-right, var(--ha-space-0));
} }
} }
@@ -187,7 +187,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="actions"]::slotted(*) { slot[name="actions"]::slotted(*) {
padding: 16px; padding: var(--ha-space-4);
} }
.scroller { .scroller {
@@ -195,7 +195,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="content"]::slotted(*) { slot[name="content"]::slotted(*) {
padding: var(--dialog-content-padding, 24px); padding: var(--dialog-content-padding, var(--ha-space-6));
} }
.scrim { .scrim {
z-index: 10; /* overlay navigation */ z-index: 10; /* overlay navigation */

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view"; import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box"; import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box"; import type { HaComboBox } from "./ha-combo-box";
@@ -44,7 +45,7 @@ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard", icon: panel.icon ?? "mdi:view-dashboard",
title: title:
panel.url_path === hass.defaultPanel panel.url_path === getDefaultPanelUrlPath(hass)
? hass.localize("panel.states") ? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) || : hass.localize(`panel.${panel.title}`) ||
panel.title || panel.title ||

View File

@@ -33,6 +33,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox"; import "./ha-checkbox";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-markdown";
import "./ha-selector/ha-selector"; import "./ha-selector/ha-selector";
import "./ha-service-picker"; import "./ha-service-picker";
import "./ha-service-section-icon"; import "./ha-service-section-icon";
@@ -684,10 +685,14 @@ export class HaServiceControl extends LitElement {
dataField.key}</span dataField.key}</span
> >
<span slot="description" <span slot="description"
>${this.hass.localize( ><ha-markdown
breaks
allow-svg
.content=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description` `component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span ) || dataField?.description}
> ></ha-markdown>
</span>
<ha-selector <ha-selector
.context=${this._selectorContext(targetEntities)} .context=${this._selectorContext(targetEntities)}
.disabled=${this.disabled || .disabled=${this.disabled ||

View File

@@ -33,6 +33,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle"; import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend"; import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification"; import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -142,7 +143,7 @@ const defaultPanelSorter = (
export const computePanels = memoizeOne( export const computePanels = memoizeOne(
( (
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"], defaultPanel: string,
panelsOrder: string[], panelsOrder: string[],
hiddenPanels: string[], hiddenPanels: string[],
locale: HomeAssistant["locale"] locale: HomeAssistant["locale"]
@@ -298,7 +299,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize || hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale || hass.locale !== oldHass.locale ||
hass.states !== oldHass.states || hass.states !== oldHass.states ||
hass.defaultPanel !== oldHass.defaultPanel || hass.userData !== oldHass.userData ||
hass.systemData !== oldHass.systemData ||
hass.connected !== oldHass.connected hass.connected !== oldHass.connected
); );
} }
@@ -401,9 +403,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`; `;
} }
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
this.hass.defaultPanel, defaultPanel,
this._panelOrder, this._panelOrder,
this._hiddenPanels, this._hiddenPanels,
this.hass.locale this.hass.locale
@@ -418,23 +422,27 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
${this._renderPanels(beforeSpacer, selectedPanel)} ${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)} ${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)}
${this._renderExternalConfiguration()} ${this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
`; `;
} }
private _renderPanels(panels: PanelInfo[], selectedPanel: string) { private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(
panel.url_path, panel.url_path,
panel.url_path === this.hass.defaultPanel panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states") ? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title, : this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon, panel.icon,
panel.url_path === this.hass.defaultPanel && !panel.icon panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace ? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]

View File

@@ -62,6 +62,10 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = []; private _candidatesList: RTCIceCandidate[] = [];
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -18,7 +18,7 @@ import {
removeLocalMedia, removeLocalMedia,
} from "../../data/media_source"; } from "../../data/media_source";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-button"; import "../ha-button";
import "../ha-check-list-item"; import "../ha-check-list-item";
@@ -305,6 +305,7 @@ class DialogMediaManage extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -314,9 +315,9 @@ class DialogMediaManage extends LitElement {
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--dialog-surface-position: fixed; --mdc-dialog-max-height: calc(
--dialog-surface-top: 40px; 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
--mdc-dialog-max-height: calc(100vh - 72px); );
} }
} }

View File

@@ -19,7 +19,7 @@ import type {
MediaPlayerItem, MediaPlayerItem,
MediaPlayerLayoutType, MediaPlayerLayoutType,
} from "../../data/media-player"; } from "../../data/media-player";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-dialog"; import "../ha-dialog";
import "../ha-dialog-header"; import "../ha-dialog-header";
@@ -223,6 +223,7 @@ class DialogMediaPlayerBrowse extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -230,23 +231,27 @@ class DialogMediaPlayerBrowse extends LitElement {
} }
ha-media-player-browse { ha-media-player-browse {
--media-browser-max-height: calc(100vh - 65px); --media-browser-max-height: calc(
100vh - 65px - var(--safe-area-inset-y)
);
} }
:host(.opened) ha-media-player-browse { :host(.opened) ha-media-player-browse {
height: calc(100vh - 65px); height: calc(100vh - 65px - var(--safe-area-inset-y));
} }
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--dialog-surface-position: fixed; --mdc-dialog-max-height: calc(
--dialog-surface-top: 40px; 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
--mdc-dialog-max-height: calc(100vh - 72px); );
} }
ha-media-player-browse { ha-media-player-browse {
position: initial; position: initial;
--media-browser-max-height: calc(100vh - 145px); --media-browser-max-height: calc(
100vh - 145px - var(--safe-area-inset-y)
);
width: 700px; width: 700px;
} }
} }

View File

@@ -34,6 +34,7 @@ class SearchInput extends LitElement {
return html` return html`
<ha-textfield <ha-textfield
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")} .label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""} .value=${this.filter || ""}
icon icon

View File

@@ -5,7 +5,6 @@ export interface AnalyticsPreferences {
diagnostics?: boolean; diagnostics?: boolean;
usage?: boolean; usage?: boolean;
statistics?: boolean; statistics?: boolean;
snapshots?: boolean;
} }
export interface Analytics { export interface Analytics {

View File

@@ -1,3 +1,4 @@
import { getAreasFloorHierarchy } from "../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../common/entity/compute_area_name"; import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -12,11 +13,7 @@ import {
} from "./device_registry"; } from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity"; import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry"; import type { EntityRegistryDisplayEntry } from "./entity_registry";
import { import type { FloorRegistryEntry } from "./floor_registry";
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem { export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area"; type: "floor" | "area";
@@ -182,34 +179,20 @@ export const getAreasAndFloors = (
); );
} }
const floorAreaLookup = getFloorAreaLookup(outputAreas); const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const items: FloorComboBoxItem[] = []; const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => { hierarchy.floors.forEach((f) => {
if (floor) { const floor = haFloors[f.id];
const floorAreas = f.areas.map((areaId) => haAreas[areaId]);
const floorName = computeFloorName(floor); const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas const areaSearchLabels = floorAreas
.map((area) => { .map((area) => {
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area);
return [area.area_id, areaName, ...area.aliases]; return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases];
}) })
.flat(); .flat();
@@ -226,24 +209,29 @@ export const getAreasAndFloors = (
...areaSearchLabels, ...areaSearchLabels,
], ],
}); });
}
items.push( items.push(
...floorAreas.map((area) => { ...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area);
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const, type: "area" as const,
primary: areaName, primary: areaName || area.area_id,
area: area, area: area,
icon: area.icon || undefined, icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases], search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
}; };
}) })
); );
}); });
items.push( items.push(
...unassignedAreas.map((area) => { ...hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area) || area.area_id;
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),

View File

@@ -59,6 +59,15 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
area_id: areaId, area_id: areaId,
}); });
export const reorderAreaRegistryEntries = (
hass: HomeAssistant,
areaIds: string[]
) =>
hass.callWS({
type: "config/area_registry/reorder",
area_ids: areaIds,
});
export const getAreaEntityLookup = ( export const getAreaEntityLookup = (
entities: EntityRegistryEntry[] entities: EntityRegistryEntry[]
): AreaEntityLookup => { ): AreaEntityLookup => {

View File

@@ -10,6 +10,7 @@ import {
import { formatTime } from "../common/datetime/format_time"; import { formatTime } from "../common/datetime/format_time";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { handleFetchPromise } from "../util/hass-call-api"; import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager"; import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
@@ -414,7 +415,7 @@ ${hass.auth.data.hassUrl}
${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")} ${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")}
${encryptionKey} ${encryptionKey}
${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: "https://www.home-assistant.io/more-info/backup-emergency-kit" })}`); ${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: documentationUrl(hass, "/more-info/backup-emergency-kit") })}`);
export const geneateEmergencyKitFileName = ( export const geneateEmergencyKitFileName = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -31,6 +31,7 @@ export interface CalendarEventData {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
export interface CalendarEventMutableParams { export interface CalendarEventMutableParams {
@@ -39,6 +40,7 @@ export interface CalendarEventMutableParams {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
// The scope of a delete/update for a recurring event // The scope of a delete/update for a recurring event
@@ -96,6 +98,7 @@ export const fetchCalendarEvents = async (
uid: ev.uid, uid: ev.uid,
summary: ev.summary, summary: ev.summary,
description: ev.description, description: ev.description,
location: ev.location,
dtstart: eventStart, dtstart: eventStart,
dtend: eventEnd, dtend: eventEnd,
recurrence_id: ev.recurrence_id, recurrence_id: ev.recurrence_id,

View File

@@ -51,6 +51,15 @@ export const deleteFloorRegistryEntry = (
floor_id: floorId, floor_id: floorId,
}); });
export const reorderFloorRegistryEntries = (
hass: HomeAssistant,
floorIds: string[]
) =>
hass.callWS({
type: "config/floor_registry/reorder",
floor_ids: floorIds,
});
export const getFloorAreaLookup = ( export const getFloorAreaLookup = (
areas: AreaRegistryEntry[] areas: AreaRegistryEntry[]
): FloorAreaLookup => { ): FloorAreaLookup => {

View File

@@ -3,6 +3,7 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData { export interface CoreFrontendUserData {
showAdvanced?: boolean; showAdvanced?: boolean;
showEntityIdPicker?: boolean; showEntityIdPicker?: boolean;
default_panel?: string;
} }
export interface SidebarFrontendUserData { export interface SidebarFrontendUserData {
@@ -10,15 +11,29 @@ export interface SidebarFrontendUserData {
hiddenPanels: string[]; hiddenPanels: string[];
} }
export interface CoreFrontendSystemData {
default_panel?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
}
declare global { declare global {
interface FrontendUserData { interface FrontendUserData {
core: CoreFrontendUserData; core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData; sidebar: SidebarFrontendUserData;
} }
interface FrontendSystemData {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
}
} }
export type ValidUserDataKey = keyof FrontendUserData; export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async < export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey, UserDataKey extends ValidUserDataKey,
>( >(
@@ -59,3 +74,46 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
key: userDataKey, key: userDataKey,
} }
); );
export const fetchFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
const result = await conn.sendMessagePromise<{
value: FrontendSystemData[SystemDataKey] | null;
}>({
type: "frontend/get_system_data",
key,
});
return result.value;
};
export const saveFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> =>
conn.sendMessagePromise<undefined>({
type: "frontend/set_system_data",
key,
value,
});
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
systemDataKey: SystemDataKey,
onChange: (data: { value: FrontendSystemData[SystemDataKey] | null }) => void
) =>
conn.subscribeMessage<{ value: FrontendSystemData[SystemDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_system_data",
key: systemDataKey,
}
);

78
src/data/labs.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
export interface LabPreviewFeature {
preview_feature: string;
domain: string;
enabled: boolean;
is_built_in: boolean;
feedback_url?: string;
learn_more_url?: string;
report_issue_url?: string;
}
export interface LabPreviewFeaturesResponse {
features: LabPreviewFeature[];
}
export const fetchLabFeatures = async (
hass: HomeAssistant
): Promise<LabPreviewFeature[]> => {
const response = await hass.callWS<LabPreviewFeaturesResponse>({
type: "labs/list",
});
return response.features;
};
export const labsUpdatePreviewFeature = (
hass: HomeAssistant,
domain: string,
preview_feature: string,
enabled: boolean,
create_backup?: boolean
): Promise<void> =>
hass.callWS({
type: "labs/update",
domain,
preview_feature,
enabled,
...(create_backup !== undefined && { create_backup }),
});
const fetchLabFeaturesCollection = (conn: Connection) =>
conn
.sendMessagePromise<LabPreviewFeaturesResponse>({
type: "labs/list",
})
.then((response) => response.features);
const subscribeLabUpdates = (
conn: Connection,
store: Store<LabPreviewFeature[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) =>
store.setState(features, true)
),
500,
true
),
"labs_updated"
);
export const subscribeLabFeatures = (
conn: Connection,
onChange: (features: LabPreviewFeature[]) => void
) =>
createCollection<LabPreviewFeature[]>(
"_labFeatures",
fetchLabFeaturesCollection,
subscribeLabUpdates,
conn,
onChange
);

View File

@@ -1,26 +1,24 @@
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant, PanelInfo } from "../types"; import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */ /** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace"; export const DEFAULT_PANEL = "lovelace";
export const getStorageDefaultPanelUrlPath = (): string => { export const getLegacyDefaultPanelUrlPath = (): string | null => {
const defaultPanel = window.localStorage.getItem("defaultPanel"); const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : null;
return defaultPanel ? JSON.parse(defaultPanel) : DEFAULT_PANEL;
}; };
export const setDefaultPanel = ( export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
element: HTMLElement, hass.userData?.default_panel ||
urlPath: string hass.systemData?.default_panel ||
): void => { getLegacyDefaultPanelUrlPath() ||
fireEvent(element, "hass-default-panel", { defaultPanel: urlPath }); DEFAULT_PANEL;
};
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
hass.panels[hass.defaultPanel] const panel = getDefaultPanelUrlPath(hass);
? hass.panels[hass.defaultPanel]
: hass.panels[DEFAULT_PANEL]; return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => { export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") { if (panel.url_path === "lovelace") {

View File

@@ -72,6 +72,7 @@ export type TranslationCategory =
| "system_health" | "system_health"
| "application_credentials" | "application_credentials"
| "issues" | "issues"
| "preview_features"
| "selector" | "selector"
| "services" | "services"
| "triggers"; | "triggers";

View File

@@ -50,7 +50,7 @@ import { lightSupportsFavoriteColors } from "../../data/light";
import type { ItemType } from "../../data/search"; import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search"; import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor"; import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import "../../state-summary/state-card-content"; import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { import {
@@ -707,14 +707,9 @@ export class MoreInfoDialog extends LitElement {
static get styles() { static get styles() {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: max(
var(--ha-space-10),
var(--safe-area-inset-top, var(--ha-space-0))
);
--dialog-content-padding: 0; --dialog-content-padding: 0;
} }
@@ -737,13 +732,6 @@ export class MoreInfoDialog extends LitElement {
display: block; display: block;
} }
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: var(--ha-space-0);
}
}
@media all and (min-width: 600px) and (min-height: 501px) { @media all and (min-width: 600px) and (min-height: 501px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: 580px; --mdc-dialog-min-width: 580px;

View File

@@ -46,7 +46,11 @@ import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config"; import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse"; import { HaFuse } from "../../resources/fuse";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles"; import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
@@ -986,6 +990,7 @@ export class QuickBar extends LitElement {
return [ return [
haStyleScrollbar, haStyleScrollbar,
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-list { ha-list {
position: relative; position: relative;
@@ -1010,9 +1015,9 @@ export class QuickBar extends LitElement {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px; --mdc-dialog-min-width: 500px;
--dialog-surface-position: fixed; --mdc-dialog-max-height: calc(
--dialog-surface-top: var(--ha-space-10); 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
--mdc-dialog-max-height: calc(100% - var(--ha-space-18)); );
} }
} }

View File

@@ -20,6 +20,7 @@ import {
} from "../../data/frontend"; } from "../../data/frontend";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showConfirmationDialog } from "../generic/show-dialog-box";
import { getDefaultPanelUrlPath } from "../../data/panel";
@customElement("dialog-edit-sidebar") @customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement { class DialogEditSidebar extends LitElement {
@@ -94,9 +95,11 @@ class DialogEditSidebar extends LitElement {
const panels = this._panels(this.hass.panels); const panels = this._panels(this.hass.panels);
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
this.hass.defaultPanel, defaultPanel,
this._order, this._order,
this._hidden, this._hidden,
this.hass.locale this.hass.locale
@@ -120,12 +123,12 @@ class DialogEditSidebar extends LitElement {
].map((panel) => ({ ].map((panel) => ({
value: panel.url_path, value: panel.url_path,
label: label:
panel.url_path === this.hass.defaultPanel panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states") ? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?", : this.hass.localize(`panel.${panel.title}`) || panel.title || "?",
icon: panel.icon || undefined, icon: panel.icon || undefined,
iconPath: iconPath:
panel.url_path === this.hass.defaultPanel && !panel.icon panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace ? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]

View File

@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event"; import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
@@ -46,10 +46,13 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
const sidebarNarrow = this._sidebarNarrow || this._externalSidebar; const sidebarNarrow = this._sidebarNarrow || this._externalSidebar;
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
return html` return html`
<ha-drawer <ha-drawer
.type=${sidebarNarrow ? "modal" : ""} .type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : undefined} .open=${sidebarNarrow ? this._drawerOpen : false}
.direction=${computeRTLDirection(this.hass)} .direction=${computeRTLDirection(this.hass)}
@MDCDrawer:closed=${this._drawerClosed} @MDCDrawer:closed=${this._drawerClosed}
> >
@@ -59,12 +62,14 @@ export class HomeAssistantMain extends LitElement {
.route=${this.route} .route=${this.route}
.alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"} .alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"}
></ha-sidebar> ></ha-sidebar>
<partial-panel-resolver ${isPanelReady
? html`<partial-panel-resolver
.narrow=${this.narrow} .narrow=${this.narrow}
.hass=${this.hass} .hass=${this.hass}
.route=${this.route} .route=${this.route}
slot="appContent" slot="appContent"
></partial-panel-resolver> ></partial-panel-resolver>`
: nothing}
</ha-drawer> </ha-drawer>
`; `;
} }

View File

@@ -1,10 +1,10 @@
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { html } from "lit"; import { html } from "lit";
import { customElement, state } from "lit/decorators"; import { customElement, state } from "lit/decorators";
import type { Connection } from "home-assistant-js-websocket"; import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click"; import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import { getStorageDefaultPanelUrlPath } from "../data/panel";
import type { WindowWithPreloads } from "../data/preloads"; import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder"; import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder"; import { getRecorderInfo } from "../data/recorder";
@@ -23,7 +23,6 @@ import {
} from "../util/register-service-worker"; } from "../util/register-service-worker";
import "./ha-init-page"; import "./ha-init-page";
import "./home-assistant-main"; import "./home-assistant-main";
import { storage } from "../common/decorators/storage";
const useHash = __DEMO__; const useHash = __DEMO__;
const curPath = () => const curPath = () =>
@@ -53,11 +52,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
super(); super();
const path = curPath(); const path = curPath();
if (["", "/"].includes(path)) {
navigate(`/${getStorageDefaultPanelUrlPath()}${location.search}`, {
replace: true,
});
}
this._route = { this._route = {
prefix: "", prefix: "",
path, path,

View File

@@ -5,6 +5,7 @@ import { atLeastVersion } from "../common/config/version";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import "../components/ha-card"; import "../components/ha-card";
import { haStyle } from "../resources/styles"; import { haStyle } from "../resources/styles";
import { documentationUrl } from "../util/documentation-url";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./hass-subpage"; import "./hass-subpage";
@@ -57,7 +58,7 @@ class SupervisorErrorScreen extends LitElement {
</li> </li>
<li> <li>
<a <a
href="https://www.home-assistant.io/help/" href=${documentationUrl(this.hass, "/help/")}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View File

@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card"; import "../components/ha-card";
import { documentationUrl } from "../util/documentation-url";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { showAppDialog } from "./dialogs/show-app-dialog"; import { showAppDialog } from "./dialogs/show-app-dialog";
import { showCommunityDialog } from "./dialogs/show-community-dialog"; import { showCommunityDialog } from "./dialogs/show-community-dialog";
@@ -22,7 +23,10 @@ class OnboardingWelcomeLinks extends LitElement {
return html`<a return html`<a
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
href="https://www.home-assistant.io/blog/2016/01/19/perfect-home-automation/" href=${documentationUrl(
this.hass,
"/blog/2016/01/19/perfect-home-automation/"
)}
> >
<onboarding-welcome-link <onboarding-welcome-link
noninteractive noninteractive

View File

@@ -80,10 +80,12 @@ class DialogCalendarEventDetail extends LitElement {
${this._data!.rrule ${this._data!.rrule
? this._renderRRuleAsText(this._data.rrule) ? this._renderRRuleAsText(this._data.rrule)
: ""} : ""}
${this._data.location
? html`${this._data.location} <br />`
: nothing}
${this._data.description ${this._data.description
? html`<br /> ? html`<br />
<div class="description">${this._data.description}</div> <div class="description">${this._data.description}</div>`
<br />`
: nothing} : nothing}
</div> </div>
</div> </div>
@@ -241,7 +243,7 @@ class DialogCalendarEventDetail extends LitElement {
haStyleDialog, haStyleDialog,
css` css`
state-info { state-info {
line-height: 40px; margin-top: 24px;
} }
ha-svg-icon { ha-svg-icon {
width: 40px; width: 40px;

View File

@@ -63,6 +63,8 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _description? = ""; @state() private _description? = "";
@state() private _location? = "";
@state() private _rrule?: string; @state() private _rrule?: string;
@state() private _allDay = false; @state() private _allDay = false;
@@ -79,6 +81,8 @@ class DialogCalendarEventEditor extends LitElement {
// timezone, but floating without a timezone. // timezone, but floating without a timezone.
private _timeZone?: string; private _timeZone?: string;
private _hasLocation = false;
public showDialog(params: CalendarEventEditDialogParams): void { public showDialog(params: CalendarEventEditDialogParams): void {
this._error = undefined; this._error = undefined;
this._info = undefined; this._info = undefined;
@@ -99,6 +103,10 @@ class DialogCalendarEventEditor extends LitElement {
this._allDay = isDate(entry.dtstart); this._allDay = isDate(entry.dtstart);
this._summary = entry.summary; this._summary = entry.summary;
this._description = entry.description; this._description = entry.description;
if (entry.location) {
this._hasLocation = true;
this._location = entry.location;
}
this._rrule = entry.rrule; this._rrule = entry.rrule;
if (this._allDay) { if (this._allDay) {
this._dtstart = new Date(entry.dtstart + "T00:00:00"); this._dtstart = new Date(entry.dtstart + "T00:00:00");
@@ -130,6 +138,8 @@ class DialogCalendarEventEditor extends LitElement {
this._dtend = undefined; this._dtend = undefined;
this._summary = ""; this._summary = "";
this._description = ""; this._description = "";
this._location = "";
this._hasLocation = false;
this._rrule = undefined; this._rrule = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@@ -181,6 +191,15 @@ class DialogCalendarEventEditor extends LitElement {
.validationMessage=${this.hass.localize("ui.common.error_required")} .validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus dialogInitialFocus
></ha-textfield> ></ha-textfield>
<ha-textfield
class="location"
name="location"
.label=${this.hass.localize(
"ui.components.calendar.event.location"
)}
.value=${this._location}
@change=${this._handleLocationChanged}
></ha-textfield>
<ha-textarea <ha-textarea
class="description" class="description"
name="description" name="description"
@@ -326,6 +345,10 @@ class DialogCalendarEventEditor extends LitElement {
this._description = ev.target.value; this._description = ev.target.value;
} }
private _handleLocationChanged(ev: Event) {
this._location = (ev.target as HTMLInputElement).value;
}
private _handleRRuleChanged(ev) { private _handleRRuleChanged(ev) {
this._rrule = ev.detail.value; this._rrule = ev.detail.value;
} }
@@ -399,6 +422,7 @@ class DialogCalendarEventEditor extends LitElement {
const data: CalendarEventMutableParams = { const data: CalendarEventMutableParams = {
summary: this._summary, summary: this._summary,
description: this._description, description: this._description,
location: this._location || (this._hasLocation ? "" : undefined),
rrule: this._rrule || undefined, rrule: this._rrule || undefined,
dtstart: "", dtstart: "",
dtend: "", dtend: "",

View File

@@ -1,21 +1,17 @@
import { ReactiveElement } from "lit"; import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import { import {
findEntities, findEntities,
generateEntityFilter, generateEntityFilter,
type EntityFilter, type EntityFilter,
} from "../../../common/entity/entity_filter"; } from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section"; import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
export interface ClimateViewStrategyConfig { export interface ClimateViewStrategyConfig {
type: "climate"; type: "climate";
@@ -139,9 +135,9 @@ export class ClimateViewStrategy extends ReactiveElement {
_config: ClimateViewStrategyConfig, _config: ClimateViewStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
): Promise<LovelaceViewConfig> { ): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas); const areas = Object.values(hass.areas);
const floors = getFloors(hass.floors); const floors = Object.values(hass.floors);
const home = getHomeStructure(floors, areas); const hierarchy = getAreasFloorHierarchy(floors, areas);
const sections: LovelaceSectionRawConfig[] = []; const sections: LovelaceSectionRawConfig[] = [];
@@ -153,10 +149,11 @@ export class ClimateViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, climateFilters); const entities = findEntities(allEntities, climateFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0); const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
// Process floors // Process floors
for (const floorStructure of home.floors) { for (const floorStructure of hierarchy.floors) {
const floorId = floorStructure.id; const floorId = floorStructure.id;
const areaIds = floorStructure.areas; const areaIds = floorStructure.areas;
const floor = hass.floors[floorId]; const floor = hass.floors[floorId];
@@ -185,7 +182,7 @@ export class ClimateViewStrategy extends ReactiveElement {
} }
// Process unassigned areas // Process unassigned areas
if (home.areas.length > 0) { if (hierarchy.areas.length > 0) {
const section: LovelaceSectionRawConfig = { const section: LovelaceSectionRawConfig = {
type: "grid", type: "grid",
column_span: 2, column_span: 2,
@@ -200,7 +197,7 @@ export class ClimateViewStrategy extends ReactiveElement {
], ],
}; };
const areaCards = processAreasForClimate(home.areas, hass, entities); const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
if (areaCards.length > 0) { if (areaCards.length > 0) {
section.cards!.push(...areaCards); section.cards!.push(...areaCards);

View File

@@ -2,38 +2,47 @@ import type { ActionDetail } from "@material/mwc-list";
import { import {
mdiDelete, mdiDelete,
mdiDotsVertical, mdiDotsVertical,
mdiDragHorizontalVariant,
mdiHelpCircle, mdiHelpCircle,
mdiPencil, mdiPencil,
mdiPlus, mdiPlus,
} from "@mdi/js"; } from "@mdi/js";
import { import {
LitElement,
type PropertyValues,
type TemplateResult,
css, css,
html, html,
LitElement,
nothing, nothing,
type PropertyValues,
type TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import {
getAreasFloorHierarchy,
getAreasOrder,
getFloorOrder,
type AreasFloorHierarchy,
} from "../../../common/areas/areas-floor-hierarchy";
import { formatListWithAnds } from "../../../common/string/format-list"; import { formatListWithAnds } from "../../../common/string/format-list";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-floor-icon"; import "../../../components/ha-floor-icon";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item"; import "../../../components/ha-list-item";
import "../../../components/ha-sortable"; import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry"; import type { AreaRegistryEntry } from "../../../data/area_registry";
import { import {
createAreaRegistryEntry, createAreaRegistryEntry,
reorderAreaRegistryEntries,
updateAreaRegistryEntry, updateAreaRegistryEntry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry"; import type { FloorRegistryEntry } from "../../../data/floor_registry";
import { import {
createFloorRegistryEntry, createFloorRegistryEntry,
deleteFloorRegistryEntry, deleteFloorRegistryEntry,
getFloorAreaLookup, reorderFloorRegistryEntries,
updateFloorRegistryEntry, updateFloorRegistryEntry,
} from "../../../data/floor_registry"; } from "../../../data/floor_registry";
import { import {
@@ -42,6 +51,7 @@ import {
} from "../../../dialogs/generic/show-dialog-box"; } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import "../ha-config-section"; import "../ha-config-section";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { import {
@@ -52,7 +62,17 @@ import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-deta
const UNASSIGNED_FLOOR = "__unassigned__"; const UNASSIGNED_FLOOR = "__unassigned__";
const SORT_OPTIONS = { sort: false, delay: 500, delayOnTouchOnly: true }; const SORT_OPTIONS: HaSortableOptions = {
sort: true,
delay: 500,
delayOnTouchOnly: true,
};
interface AreaStats {
devices: number;
services: number;
entities: number;
}
@customElement("ha-config-areas-dashboard") @customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement { export class HaConfigAreasDashboard extends LitElement {
@@ -64,55 +84,50 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
@state() private _areas: AreaRegistryEntry[] = []; @state() private _hierarchy?: AreasFloorHierarchy;
private _processAreas = memoizeOne( private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
private _processAreasStats = memoizeOne(
( (
areas: AreaRegistryEntry[], areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"], devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"], entities: HomeAssistant["entities"]
floors: HomeAssistant["floors"] ): Map<string, AreaStats> => {
) => { const computeAreaStats = (area: AreaRegistryEntry) => {
const processArea = (area: AreaRegistryEntry) => { let devicesCount = 0;
let noDevicesInArea = 0; let servicesCount = 0;
let noServicesInArea = 0; let entitiesCount = 0;
let noEntitiesInArea = 0;
for (const device of Object.values(devices)) { for (const device of Object.values(devices)) {
if (device.area_id === area.area_id) { if (device.area_id === area.area_id) {
if (device.entry_type === "service") { if (device.entry_type === "service") {
noServicesInArea++; servicesCount++;
} else { } else {
noDevicesInArea++; devicesCount++;
} }
} }
} }
for (const entity of Object.values(entities)) { for (const entity of Object.values(entities)) {
if (entity.area_id === area.area_id) { if (entity.area_id === area.area_id) {
noEntitiesInArea++; entitiesCount++;
} }
} }
return { return {
...area, devices: devicesCount,
devices: noDevicesInArea, services: servicesCount,
services: noServicesInArea, entities: entitiesCount,
entities: noEntitiesInArea,
}; };
}; };
const areaStats = new Map<string, AreaStats>();
const floorAreaLookup = getFloorAreaLookup(areas); Object.values(areas).forEach((area) => {
const unassignedAreas = areas.filter( areaStats.set(area.area_id, computeAreaStats(area));
(area) => !area.floor_id || !floorAreaLookup[area.floor_id] });
); return areaStats;
return {
floors: Object.values(floors).map((floor) => ({
...floor,
areas: (floorAreaLookup[floor.floor_id] || []).map(processArea),
})),
unassignedAreas: unassignedAreas.map(processArea),
};
} }
); );
@@ -120,24 +135,31 @@ export class HaConfigAreasDashboard extends LitElement {
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
if (changedProperties.has("hass")) { if (changedProperties.has("hass")) {
const oldHass = changedProperties.get("hass"); const oldHass = changedProperties.get("hass");
if (this.hass.areas !== oldHass?.areas) { if (
this._areas = Object.values(this.hass.areas); (this.hass.areas !== oldHass?.areas ||
this.hass.floors !== oldHass?.floors) &&
!this._blockHierarchyUpdate
) {
this._computeHierarchy();
} }
} }
} }
protected render(): TemplateResult { private _computeHierarchy() {
const areasAndFloors = this._hierarchy = getAreasFloorHierarchy(
!this.hass.areas || Object.values(this.hass.floors),
!this.hass.devices || Object.values(this.hass.areas)
!this.hass.entities || );
!this.hass.floors }
? undefined
: this._processAreas( protected render(): TemplateResult<1> | typeof nothing {
this._areas, if (!this._hierarchy) {
return nothing;
}
const areasStats = this._processAreasStats(
this.hass.areas,
this.hass.devices, this.hass.devices,
this.hass.entities, this.hass.entities
this.hass.floors
); );
return html` return html`
@@ -157,14 +179,32 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></ha-icon-button>
<div class="container"> <div class="container">
${areasAndFloors?.floors.map( <ha-sortable
(floor) => handle-selector=".handle"
html`<div class="floor"> draggable-selector=".floor"
@item-moved=${this._floorMoved}
.options=${SORT_OPTIONS}
group="floors"
invert-swap
>
<div class="floors">
${this._hierarchy.floors.map(({ areas, id }) => {
const floor = this.hass.floors[id];
if (!floor) {
return nothing;
}
return html`
<div class="floor">
<div class="header"> <div class="header">
<h2> <h2>
<ha-floor-icon .floor=${floor}></ha-floor-icon> <ha-floor-icon .floor=${floor}></ha-floor-icon>
${floor.name} ${floor.name}
</h2> </h2>
<div class="actions">
<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-button-menu <ha-button-menu
.floor=${floor} .floor=${floor}
@action=${this._handleFloorAction} @action=${this._handleFloorAction}
@@ -194,22 +234,36 @@ export class HaConfigAreasDashboard extends LitElement {
> >
</ha-button-menu> </ha-button-menu>
</div> </div>
</div>
<ha-sortable <ha-sortable
handle-selector="a" handle-selector="a"
draggable-selector="a" draggable-selector="a"
@item-added=${this._areaAdded} @item-added=${this._areaAdded}
group="floor" @item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS} .options=${SORT_OPTIONS}
.floor=${floor.floor_id} .floor=${floor.floor_id}
> >
<div class="areas"> <div class="areas">
${floor.areas.map((area) => this._renderArea(area))} ${areas.map((areaId) => {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div> </div>
</ha-sortable> </ha-sortable>
</div>` </div>
)} `;
${areasAndFloors?.unassignedAreas.length })}
? html`<div class="floor"> </div>
</ha-sortable>
${this._hierarchy.areas.length
? html`
<div class="floor">
<div class="header"> <div class="header">
<h2> <h2>
${this.hass.localize( ${this.hass.localize(
@@ -221,17 +275,24 @@ export class HaConfigAreasDashboard extends LitElement {
handle-selector="a" handle-selector="a"
draggable-selector="a" draggable-selector="a"
@item-added=${this._areaAdded} @item-added=${this._areaAdded}
group="floor" @item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS} .options=${SORT_OPTIONS}
.floor=${UNASSIGNED_FLOOR} .floor=${UNASSIGNED_FLOOR}
> >
<div class="areas"> <div class="areas">
${areasAndFloors?.unassignedAreas.map((area) => ${this._hierarchy.areas.map((areaId) => {
this._renderArea(area) const area = this.hass.areas[areaId];
)} if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div> </div>
</ha-sortable> </ha-sortable>
</div>` </div>
`
: nothing} : nothing}
</div> </div>
<ha-fab <ha-fab
@@ -259,15 +320,18 @@ export class HaConfigAreasDashboard extends LitElement {
`; `;
} }
private _renderArea(area) { private _renderArea(
return html`<a area: AreaRegistryEntry,
href=${`/config/areas/area/${area.area_id}`} stats: AreaStats | undefined
.sortableData=${area} ): TemplateResult<1> {
> return html`
<a href=${`/config/areas/area/${area.area_id}`} .sortableData=${area}>
<ha-card outlined> <ha-card outlined>
<div <div
style=${styleMap({ style=${styleMap({
backgroundImage: area.picture ? `url(${area.picture})` : undefined, backgroundImage: area.picture
? `url(${area.picture})`
: undefined,
})} })}
class="picture ${!area.picture ? "placeholder" : ""}" class="picture ${!area.picture ? "placeholder" : ""}"
> >
@@ -288,27 +352,28 @@ export class HaConfigAreasDashboard extends LitElement {
${formatListWithAnds( ${formatListWithAnds(
this.hass.locale, this.hass.locale,
[ [
area.devices && stats?.devices &&
this.hass.localize( this.hass.localize(
"ui.panel.config.integrations.config_entry.devices", "ui.panel.config.integrations.config_entry.devices",
{ count: area.devices } { count: stats.devices }
), ),
area.services && stats?.services &&
this.hass.localize( this.hass.localize(
"ui.panel.config.integrations.config_entry.services", "ui.panel.config.integrations.config_entry.services",
{ count: area.services } { count: stats.services }
), ),
area.entities && stats?.entities &&
this.hass.localize( this.hass.localize(
"ui.panel.config.integrations.config_entry.entities", "ui.panel.config.integrations.config_entry.entities",
{ count: area.entities } { count: stats.entities }
), ),
].filter((v): v is string => Boolean(v)) ].filter((v): v is string => Boolean(v))
)} )}
</div> </div>
</div> </div>
</ha-card> </ha-card>
</a>`; </a>
`;
} }
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
@@ -326,24 +391,170 @@ export class HaConfigAreasDashboard extends LitElement {
}); });
} }
private async _floorMoved(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { oldIndex, newIndex } = ev.detail;
const reorderFloors = (
floors: AreasFloorHierarchy["floors"],
oldIdx: number,
newIdx: number
) => {
const newFloors = [...floors];
const [movedFloor] = newFloors.splice(oldIdx, 1);
newFloors.splice(newIdx, 0, movedFloor);
return newFloors;
};
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: reorderFloors(this._hierarchy.floors, oldIndex, newIndex),
};
const areaOrder = getAreasOrder(this._hierarchy);
const floorOrder = getFloorOrder(this._hierarchy);
// Block hierarchy updates for 500ms to avoid flickering
// because of multiple async updates
this._blockHierarchyUpdateFor(500);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await reorderFloorRegistryEntries(this.hass, floorOrder);
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.floor_reorder_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private async _areaMoved(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { floor } = ev.currentTarget;
const { oldIndex, newIndex } = ev.detail;
const floorId = floor === UNASSIGNED_FLOOR ? null : floor;
// Reorder areas within the same floor
const reorderAreas = (areas: string[], oldIdx: number, newIdx: number) => {
const newAreas = [...areas];
const [movedArea] = newAreas.splice(oldIdx, 1);
newAreas.splice(newIdx, 0, movedArea);
return newAreas;
};
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === floorId) {
return {
...f,
areas: reorderAreas(f.areas, oldIndex, newIndex),
};
}
return f;
}),
areas:
floorId === null
? reorderAreas(this._hierarchy.areas, oldIndex, newIndex)
: this._hierarchy.areas,
};
const areaOrder = getAreasOrder(this._hierarchy);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.area_move_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private async _areaAdded(ev) { private async _areaAdded(ev) {
ev.stopPropagation(); ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { floor } = ev.currentTarget; const { floor } = ev.currentTarget;
const { data: area, index } = ev.detail;
const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor; const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor;
const { data: area } = ev.detail; // Insert area at the specified index
const insertAtIndex = (areas: string[], areaId: string, idx: number) => {
const newAreas = [...areas];
newAreas.splice(idx, 0, areaId);
return newAreas;
};
this._areas = this._areas.map<AreaRegistryEntry>((a) => { // Optimistically update UI
if (a.area_id === area.area_id) { this._hierarchy = {
return { ...a, floor_id: newFloorId }; ...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === newFloorId) {
return {
...f,
areas: insertAtIndex(f.areas, area.area_id, index),
};
} }
return a; return {
}); ...f,
areas: f.areas.filter((id) => id !== area.area_id),
};
}),
areas:
newFloorId === null
? insertAtIndex(this._hierarchy.areas, area.area_id, index)
: this._hierarchy.areas.filter((id) => id !== area.area_id),
};
const areaOrder = getAreasOrder(this._hierarchy);
// Block hierarchy updates for 500ms to avoid flickering
// because of multiple async updates
this._blockHierarchyUpdateFor(500);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await updateAreaRegistryEntry(this.hass, area.area_id, { await updateAreaRegistryEntry(this.hass, area.area_id, {
floor_id: newFloorId, floor_id: newFloorId,
}); });
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.area_move_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private _blockHierarchyUpdateFor(time: number) {
this._blockHierarchyUpdate = true;
if (this._blockHierarchyUpdateTimeout) {
window.clearTimeout(this._blockHierarchyUpdateTimeout);
}
this._blockHierarchyUpdateTimeout = window.setTimeout(() => {
this._blockHierarchyUpdate = false;
}, time);
} }
private _handleFloorAction(ev: CustomEvent<ActionDetail>) { private _handleFloorAction(ev: CustomEvent<ActionDetail>) {
@@ -463,6 +674,10 @@ export class HaConfigAreasDashboard extends LitElement {
.header ha-icon { .header ha-icon {
margin-inline-end: 8px; margin-inline-end: 8px;
} }
.header .actions {
display: flex;
align-items: center;
}
.areas { .areas {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@@ -473,6 +688,10 @@ export class HaConfigAreasDashboard extends LitElement {
.areas > * { .areas > * {
max-width: 500px; max-width: 500px;
} }
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}
ha-card { ha-card {
overflow: hidden; overflow: hidden;
} }

View File

@@ -1336,7 +1336,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space); --md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-1); --md-list-item-bottom-space: var(--ha-space-1);
--md-list-item-top-space: var(--md-list-item-bottom-space); --md-list-item-top-space: var(--md-list-item-bottom-space);
--md-list-item-supporting-text-font: var(--ha-font-size-s); --md-list-item-supporting-text-font: var(--ha-font-family-body);
--md-list-item-one-line-container-height: var(--ha-space-10); --md-list-item-one-line-container-height: var(--ha-space-10);
} }
ha-bottom-sheet .groups { ha-bottom-sheet .groups {
@@ -1400,7 +1400,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space); --md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-2); --md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-top-space: var(--md-list-item-bottom-space); --md-list-item-top-space: var(--md-list-item-bottom-space);
--md-list-item-supporting-text-font: var(--ha-font-size-s); --md-list-item-supporting-text-font: var(--ha-font-family-body);
gap: var(--ha-space-2); gap: var(--ha-space-2);
padding: var(--ha-space-0) var(--ha-space-4); padding: var(--ha-space-0) var(--ha-space-4);
} }

View File

@@ -1161,6 +1161,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
private async _delete(automation) { private async _delete(automation) {
try { try {
await deleteAutomation(this.hass, automation.attributes.id); await deleteAutomation(this.hass, automation.attributes.id);
this._selected = this._selected.filter(
(entityId) => entityId !== automation.entity_id
);
} catch (err: any) { } catch (err: any) {
await showAlertDialog(this, { await showAlertDialog(this, {
text: text:

View File

@@ -2,6 +2,7 @@ import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { documentationUrl } from "../../../util/documentation-url";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-code-editor"; import "../../../components/ha-code-editor";
@@ -140,7 +141,7 @@ class DialogImportBlueprint extends LitElement {
<ha-button <ha-button
size="small" size="small"
appearance="plain" appearance="plain"
href="https://www.home-assistant.io/get-blueprints" href=${documentationUrl(this.hass, "/get-blueprints")}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >

View File

@@ -299,7 +299,7 @@ class HaBlueprintOverview extends LitElement {
> >
<ha-button <ha-button
appearance="plain" appearance="plain"
href="https://www.home-assistant.io/get-blueprints" href=${documentationUrl(this.hass, "/get-blueprints")}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
size="small" size="small"

View File

@@ -1,10 +1,14 @@
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-analytics"; import "../../../components/ha-analytics";
import "../../../components/ha-button";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-checkbox";
import "../../../components/ha-settings-row"; import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import type { Analytics } from "../../../data/analytics"; import type { Analytics } from "../../../data/analytics";
import { import {
getAnalyticsDetails, getAnalyticsDetails,
@@ -13,8 +17,6 @@ import {
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; import { documentationUrl } from "../../../util/documentation-url";
import { isDevVersion } from "../../../common/config/version";
import type { HaSwitch } from "../../../components/ha-switch";
@customElement("ha-config-analytics") @customElement("ha-config-analytics")
class ConfigAnalytics extends LitElement { class ConfigAnalytics extends LitElement {
@@ -32,22 +34,10 @@ class ConfigAnalytics extends LitElement {
: undefined; : undefined;
return html` return html`
<ha-card <ha-card outlined>
outlined
.header=${this.hass.localize("ui.panel.config.analytics.header") ||
"Home Assistant analytics"}
>
<div class="card-content"> <div class="card-content">
${error ? html`<div class="error">${error}</div>` : nothing} ${error ? html`<div class="error">${error}</div>` : ""}
<p> <p>${this.hass.localize("ui.panel.config.analytics.intro")}</p>
${this.hass.localize("ui.panel.config.analytics.intro")}
<a
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.panel.config.analytics.learn_more")}</a
>.
</p>
<ha-analytics <ha-analytics
translation_key_panel="config" translation_key_panel="config"
@analytics-preferences-changed=${this._preferencesChanged} @analytics-preferences-changed=${this._preferencesChanged}
@@ -55,50 +45,26 @@ class ConfigAnalytics extends LitElement {
.analytics=${this._analyticsDetails} .analytics=${this._analyticsDetails}
></ha-analytics> ></ha-analytics>
</div> </div>
</ha-card> <div class="card-actions">
${isDevVersion(this.hass.config.version) <ha-button @click=${this._save}>
? html`<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.header"
)}
>
<div class="card-content">
<p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.info" "ui.panel.config.core.section.core.core_config.save_button"
)} )}
<a </ha-button>
href=${documentationUrl(this.hass, "/device-database/")} </div>
</ha-card>
<div class="footer">
<ha-button
size="small"
appearance="plain"
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.learn_more"
)}</a
>.
</p>
<ha-settings-row>
<span slot="heading" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.title`
)}
</span>
<span slot="description" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.description`
)}
</span>
<ha-switch
@change=${this._handleDeviceRowClick}
.checked=${!!this._analyticsDetails?.preferences.snapshots}
.disabled=${this._analyticsDetails === undefined}
name="snapshots"
> >
</ha-switch> <ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-settings-row> ${this.hass.localize("ui.panel.config.analytics.learn_more")}
</ha-button>
</div> </div>
</ha-card>`
: nothing}
`; `;
} }
@@ -130,25 +96,11 @@ class ConfigAnalytics extends LitElement {
} }
} }
private _handleDeviceRowClick(ev: Event) {
const target = ev.target as HaSwitch;
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: {
...this._analyticsDetails!.preferences,
snapshots: target.checked,
},
};
this._save();
}
private _preferencesChanged(event: CustomEvent): void { private _preferencesChanged(event: CustomEvent): void {
this._analyticsDetails = { this._analyticsDetails = {
...this._analyticsDetails!, ...this._analyticsDetails!,
preferences: event.detail.preferences, preferences: event.detail.preferences,
}; };
this._save();
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@@ -165,10 +117,21 @@ class ConfigAnalytics extends LitElement {
p { p {
margin-top: 0; margin-top: 0;
} }
ha-card:not(:first-of-type) { .card-actions {
margin-top: 24px; display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
} }
`, .footer {
padding: 32px 0 16px;
text-align: center;
}
ha-button[size="small"] ha-svg-icon {
--mdc-icon-size: 16px;
}
`, // row-reverse so we tab first to "save"
]; ];
} }
} }

View File

@@ -23,6 +23,8 @@ import {
fetchHassioHassOsInfo, fetchHassioHassOsInfo,
fetchHassioHostInfo, fetchHassioHostInfo,
} from "../../../data/hassio/host"; } from "../../../data/hassio/host";
import type { LabPreviewFeature } from "../../../data/labs";
import { fetchLabFeatures } from "../../../data/labs";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
@@ -50,6 +52,8 @@ class HaConfigSystemNavigation extends LitElement {
@state() private _externalAccess = false; @state() private _externalAccess = false;
@state() private _labFeatures?: LabPreviewFeature[];
protected render(): TemplateResult { protected render(): TemplateResult {
const pages = configSections.general const pages = configSections.general
.filter((page) => canShowPage(this.hass, page)) .filter((page) => canShowPage(this.hass, page))
@@ -94,6 +98,12 @@ class HaConfigSystemNavigation extends LitElement {
this._boardName || this._boardName ||
this.hass.localize("ui.panel.config.hardware.description"); this.hass.localize("ui.panel.config.hardware.description");
break; break;
case "labs":
description =
this._labFeatures && this._labFeatures.some((f) => f.enabled)
? this.hass.localize("ui.panel.config.labs.description_enabled")
: this.hass.localize("ui.panel.config.labs.description");
break;
default: default:
description = this.hass.localize( description = this.hass.localize(
@@ -156,6 +166,7 @@ class HaConfigSystemNavigation extends LitElement {
const isHassioLoaded = isComponentLoaded(this.hass, "hassio"); const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
this._fetchBackupInfo(); this._fetchBackupInfo();
this._fetchHardwareInfo(isHassioLoaded); this._fetchHardwareInfo(isHassioLoaded);
this._fetchLabFeatures();
if (isHassioLoaded) { if (isHassioLoaded) {
this._fetchStorageInfo(); this._fetchStorageInfo();
} }
@@ -211,6 +222,12 @@ class HaConfigSystemNavigation extends LitElement {
this._externalAccess = this.hass.config.external_url !== null; this._externalAccess = this.hass.config.external_url !== null;
} }
private async _fetchLabFeatures() {
if (isComponentLoaded(this.hass, "labs")) {
this._labFeatures = await fetchLabFeatures(this.hass);
}
}
private async _showRestartDialog() { private async _showRestartDialog() {
showRestartDialog(this); showRestartDialog(this);
} }

View File

@@ -281,8 +281,12 @@ class DialogNewDashboard extends LitElement implements HassDialog {
@media all and (min-width: 850px) { @media all and (min-width: 850px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: 845px; --mdc-dialog-min-width: 845px;
--mdc-dialog-min-height: calc(100vh - 72px); --mdc-dialog-min-height: calc(
--mdc-dialog-max-height: calc(100vh - 72px); 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
--mdc-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
} }
} }

View File

@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import type { EnergyValidationIssue } from "../../../../data/energy"; import type { EnergyValidationIssue } from "../../../../data/energy";
import { documentationUrl } from "../../../../util/documentation-url";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
@customElement("ha-energy-validation-result") @customElement("ha-energy-validation-result")
@@ -29,7 +30,10 @@ class EnergyValidationMessage extends LitElement {
)} )}
${issue.type === "recorder_untracked" ${issue.type === "recorder_untracked"
? html`(<a ? html`(<a
href="https://www.home-assistant.io/integrations/recorder#configure-filter" href=${documentationUrl(
this.hass,
"/integrations/recorder#configure-filter"
)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>${this.hass.localize("ui.panel.config.common.learn_more")}</a >${this.hass.localize("ui.panel.config.common.learn_more")}</a

View File

@@ -7,6 +7,7 @@ import {
mdiCog, mdiCog,
mdiDatabase, mdiDatabase,
mdiDevices, mdiDevices,
mdiFlask,
mdiInformation, mdiInformation,
mdiInformationOutline, mdiInformationOutline,
mdiLabel, mdiLabel,
@@ -328,6 +329,13 @@ export const configSections: Record<string, PageNavigation[]> = {
iconPath: mdiShape, iconPath: mdiShape,
iconColor: "#f1c447", iconColor: "#f1c447",
}, },
{
path: "/config/labs",
translationKey: "labs",
iconPath: mdiFlask,
iconColor: "#b1b134",
core: true,
},
{ {
path: "/config/network", path: "/config/network",
translationKey: "network", translationKey: "network",
@@ -515,6 +523,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
tag: "ha-config-section-general", tag: "ha-config-section-general",
load: () => import("./core/ha-config-section-general"), load: () => import("./core/ha-config-section-general"),
}, },
labs: {
tag: "ha-config-labs",
load: () => import("./labs/ha-config-labs"),
},
zha: { zha: {
tag: "zha-config-dashboard-router", tag: "zha-config-dashboard-router",
load: () => load: () =>

View File

@@ -462,7 +462,7 @@ class AddIntegrationDialog extends LitElement {
style=${styleMap({ style=${styleMap({
width: `${this._width}px`, width: `${this._width}px`,
height: this._narrow height: this._narrow
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))" ? "calc(100vh - 184px - var(--safe-area-inset-top, var(--ha-space-0)) - var(--safe-area-inset-bottom, var(--ha-space-0)))"
: "500px", : "500px",
})} })}
@click=${this._integrationPicked} @click=${this._integrationPicked}

View File

@@ -87,7 +87,7 @@ class HaConfigEntryDeviceRow extends LitElement {
${!this.narrow ${!this.narrow
? html`<ha-icon-button ? html`<ha-icon-button
slot="end" slot="end"
@click=${this._handleEditDevice} @click=${this._handleEditDeviceButton}
.path=${mdiPencil} .path=${mdiPencil}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit" "ui.panel.config.integrations.config_entry.device.edit"
@@ -106,7 +106,7 @@ class HaConfigEntryDeviceRow extends LitElement {
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
></ha-icon-button> ></ha-icon-button>
${this.narrow ${this.narrow
? html`<ha-md-menu-item @click=${this._handleEditDevice}> ? html`<ha-md-menu-item .clickAction=${this._handleEditDevice}>
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon> <ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit" "ui.panel.config.integrations.config_entry.device.edit"
@@ -115,7 +115,7 @@ class HaConfigEntryDeviceRow extends LitElement {
: nothing} : nothing}
${entities.length ${entities.length
? html` ? html`
<ha-md-menu-item @click=${this._handleNavigateToEntities}> <ha-md-menu-item .clickAction=${this._handleNavigateToEntities}>
<ha-svg-icon <ha-svg-icon
.path=${mdiShapeOutline} .path=${mdiShapeOutline}
slot="start" slot="start"
@@ -130,7 +130,7 @@ class HaConfigEntryDeviceRow extends LitElement {
: nothing} : nothing}
<ha-md-menu-item <ha-md-menu-item
class=${device.disabled_by !== "user" ? "warning" : ""} class=${device.disabled_by !== "user" ? "warning" : ""}
@click=${this._handleDisableDevice} .clickAction=${this._handleDisableDevice}
.disabled=${device.disabled_by !== "user" && device.disabled_by} .disabled=${device.disabled_by !== "user" && device.disabled_by}
> >
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon> <ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
@@ -160,7 +160,7 @@ class HaConfigEntryDeviceRow extends LitElement {
${this.entry.supports_remove_device ${this.entry.supports_remove_device
? html`<ha-md-menu-item ? html`<ha-md-menu-item
class="warning" class="warning"
@click=${this._handleDeleteDevice} .clickAction=${this._handleDeleteDevice}
> >
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon> <ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
@@ -175,21 +175,25 @@ class HaConfigEntryDeviceRow extends LitElement {
private _getEntities = (): EntityRegistryEntry[] => private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id); this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleEditDevice(ev: MouseEvent) { private _handleEditDeviceButton(ev: MouseEvent) {
ev.stopPropagation(); // Prevent triggering the click handler on the list item ev.stopPropagation(); // Prevent triggering the click handler on the list item
this._handleEditDevice();
}
private _handleEditDevice = () => {
showDeviceRegistryDetailDialog(this, { showDeviceRegistryDetailDialog(this, {
device: this.device, device: this.device,
updateEntry: async (updates) => { updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this.device.id, updates); await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
}, },
}); });
} };
private _handleNavigateToEntities() { private _handleNavigateToEntities = () => {
navigate(`/config/entities/?historyBack=1&device=${this.device.id}`); navigate(`/config/entities/?historyBack=1&device=${this.device.id}`);
} };
private async _handleDisableDevice() { private _handleDisableDevice = async () => {
const disable = this.device.disabled_by === null; const disable = this.device.disabled_by === null;
if (disable) { if (disable) {
@@ -263,9 +267,9 @@ class HaConfigEntryDeviceRow extends LitElement {
await updateDeviceRegistryEntry(this.hass, this.device.id, { await updateDeviceRegistryEntry(this.hass, this.device.id, {
disabled_by: disable ? "user" : null, disabled_by: disable ? "user" : null,
}); });
} };
private async _handleDeleteDevice() { private _handleDeleteDevice = async () => {
const entry = this.entry; const entry = this.entry;
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"), text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
@@ -290,7 +294,7 @@ class HaConfigEntryDeviceRow extends LitElement {
text: err.message, text: err.message,
}); });
} }
} };
private _handleNavigateToDevice() { private _handleNavigateToDevice() {
navigate(`/config/devices/device/${this.device.id}`); navigate(`/config/devices/device/${this.device.id}`);

View File

@@ -302,7 +302,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_unload && item.supports_unload &&
item.source !== "system" item.source !== "system"
? html` ? html`
<ha-md-menu-item @click=${this._handleReload}> <ha-md-menu-item .clickAction=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload" "ui.panel.config.integrations.config_entry.reload"
@@ -311,14 +311,14 @@ class HaConfigEntryRow extends LitElement {
` `
: nothing} : nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon"> <ha-md-menu-item .clickAction=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename" "ui.panel.config.integrations.config_entry.rename"
)} )}
</ha-md-menu-item> </ha-md-menu-item>
<ha-md-menu-item @click=${this._handleCopy} graphic="icon"> <ha-md-menu-item .clickAction=${this._handleCopy} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.copy" "ui.panel.config.integrations.config_entry.copy"
@@ -328,7 +328,7 @@ class HaConfigEntryRow extends LitElement {
${Object.keys(item.supported_subentry_types).map( ${Object.keys(item.supported_subentry_types).map(
(flowType) => (flowType) =>
html`<ha-md-menu-item html`<ha-md-menu-item
@click=${this._addSubEntry} .clickAction=${this._addSubEntry}
.entry=${item} .entry=${item}
.flowType=${flowType} .flowType=${flowType}
graphic="icon" graphic="icon"
@@ -360,7 +360,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_reconfigure && item.supports_reconfigure &&
item.source !== "system" item.source !== "system"
? html` ? html`
<ha-md-menu-item @click=${this._handleReconfigure}> <ha-md-menu-item .clickAction=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure" "ui.panel.config.integrations.config_entry.reconfigure"
@@ -369,7 +369,10 @@ class HaConfigEntryRow extends LitElement {
` `
: nothing} : nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon"> <ha-md-menu-item
.clickAction=${this._handleSystemOptions}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options" "ui.panel.config.integrations.config_entry.system_options"
@@ -377,7 +380,7 @@ class HaConfigEntryRow extends LitElement {
</ha-md-menu-item> </ha-md-menu-item>
${item.disabled_by === "user" ${item.disabled_by === "user"
? html` ? html`
<ha-md-menu-item @click=${this._handleEnable}> <ha-md-menu-item .clickAction=${this._handleEnable}>
<ha-svg-icon <ha-svg-icon
slot="start" slot="start"
.path=${mdiPlayCircleOutline} .path=${mdiPlayCircleOutline}
@@ -389,7 +392,7 @@ class HaConfigEntryRow extends LitElement {
? html` ? html`
<ha-md-menu-item <ha-md-menu-item
class="warning" class="warning"
@click=${this._handleDisable} .clickAction=${this._handleDisable}
graphic="icon" graphic="icon"
> >
<ha-svg-icon <ha-svg-icon
@@ -403,7 +406,10 @@ class HaConfigEntryRow extends LitElement {
: nothing} : nothing}
${item.source !== "system" ${item.source !== "system"
? html` ? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}> <ha-md-menu-item
class="warning"
.clickAction=${this._handleDelete}
>
<ha-svg-icon <ha-svg-icon
slot="start" slot="start"
class="warning" class="warning"
@@ -611,7 +617,7 @@ class HaConfigEntryRow extends LitElement {
} }
} }
private async _handleReload() { private _handleReload = async () => {
const result = await reloadConfigEntry(this.hass, this.entry.entry_id); const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
const locale_key = result.require_restart const locale_key = result.require_restart
? "reload_restart_confirm" ? "reload_restart_confirm"
@@ -621,9 +627,9 @@ class HaConfigEntryRow extends LitElement {
`ui.panel.config.integrations.config_entry.${locale_key}` `ui.panel.config.integrations.config_entry.${locale_key}`
), ),
}); });
} };
private async _handleReconfigure() { private _handleReconfigure = async () => {
showConfigFlowDialog(this, { showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain, startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced, showAdvanced: this.hass.userData?.showAdvanced,
@@ -631,18 +637,18 @@ class HaConfigEntryRow extends LitElement {
entryId: this.entry.entry_id, entryId: this.entry.entry_id,
navigateToResult: true, navigateToResult: true,
}); });
} };
private async _handleCopy() { private _handleCopy = async () => {
await copyToClipboard(this.entry.entry_id); await copyToClipboard(this.entry.entry_id);
showToast(this, { showToast(this, {
message: message:
this.hass?.localize("ui.common.copied_clipboard") || this.hass?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard", "Copied to clipboard",
}); });
} };
private async _handleRename() { private _handleRename = async () => {
const newName = await showPromptDialog(this, { const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"), title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title, defaultValue: this.entry.title,
@@ -656,7 +662,7 @@ class HaConfigEntryRow extends LitElement {
await updateConfigEntry(this.hass, this.entry.entry_id, { await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName, title: newName,
}); });
} };
private async _signUrl(ev) { private async _signUrl(ev) {
const anchor = ev.currentTarget; const anchor = ev.currentTarget;
@@ -668,7 +674,7 @@ class HaConfigEntryRow extends LitElement {
fileDownload(signedUrl.path); fileDownload(signedUrl.path);
} }
private async _handleDisable() { private _handleDisable = async () => {
const entryId = this.entry.entry_id; const entryId = this.entry.entry_id;
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
@@ -706,9 +712,9 @@ class HaConfigEntryRow extends LitElement {
), ),
}); });
} }
} };
private async _handleEnable() { private _handleEnable = async () => {
const entryId = this.entry.entry_id; const entryId = this.entry.entry_id;
let result: DisableConfigEntryResult; let result: DisableConfigEntryResult;
@@ -731,9 +737,9 @@ class HaConfigEntryRow extends LitElement {
), ),
}); });
} }
} };
private async _handleDelete() { private _handleDelete = async () => {
const entryId = this.entry.entry_id; const entryId = this.entry.entry_id;
const applicationCredentialsId = const applicationCredentialsId =
@@ -767,20 +773,20 @@ class HaConfigEntryRow extends LitElement {
if (applicationCredentialsId) { if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId); this._removeApplicationCredential(applicationCredentialsId);
} }
} };
private _handleSystemOptions() { private _handleSystemOptions = () => {
showConfigEntrySystemOptionsDialog(this, { showConfigEntrySystemOptionsDialog(this, {
entry: this.entry, entry: this.entry,
manifest: this.manifest, manifest: this.manifest,
}); });
} };
private _addSubEntry(ev) { private _addSubEntry = (item) => {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, { showSubConfigFlowDialog(this, this.entry, item.flowType, {
startFlowHandler: this.entry.entry_id, startFlowHandler: this.entry.entry_id,
}); });
} };
static styles = [ static styles = [
haStyle, haStyle,

View File

@@ -1,4 +1,10 @@
import { mdiBookshelf, mdiCog, mdiDotsVertical, mdiOpenInNew } from "@mdi/js"; import {
mdiBookshelf,
mdiCog,
mdiDelete,
mdiDotsVertical,
mdiOpenInNew,
} from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@@ -7,6 +13,11 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-list-item"; import "../../../components/ha-list-item";
import {
deleteApplicationCredential,
fetchApplicationCredentialsConfigEntry,
} from "../../../data/application_credential";
import { deleteConfigEntry } from "../../../data/config_entries";
import { import {
ATTENTION_SOURCES, ATTENTION_SOURCES,
DISCOVERY_SOURCES, DISCOVERY_SOURCES,
@@ -15,7 +26,10 @@ import {
} from "../../../data/config_flow"; } from "../../../data/config_flow";
import type { IntegrationManifest } from "../../../data/integration"; import type { IntegrationManifest } from "../../../data/integration";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; import { documentationUrl } from "../../../util/documentation-url";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
@@ -60,7 +74,7 @@ export class HaConfigFlowCard extends LitElement {
: "ui.common.add" : "ui.common.add"
)} )}
</ha-button> </ha-button>
${this.flow.context.configuration_url || this.manifest ${this.flow.context.configuration_url || this.manifest || attention
? html`<ha-button-menu slot="header-button"> ? html`<ha-button-menu slot="header-button">
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
@@ -118,6 +132,22 @@ export class HaConfigFlowCard extends LitElement {
</ha-list-item> </ha-list-item>
</a>` </a>`
: ""} : ""}
${attention
? html`<ha-list-item
class="warning"
graphic="icon"
@click=${this._handleDelete}
>
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-list-item>`
: ""}
</ha-button-menu>` </ha-button-menu>`
: ""} : ""}
</ha-integration-action-card> </ha-integration-action-card>
@@ -175,6 +205,109 @@ export class HaConfigFlowCard extends LitElement {
}); });
} }
// Return an application credentials id for this config entry to prompt the
// user for removal. This is best effort so we don't stop overall removal
// if the integration isn't loaded or there is some other error.
private async _fetchApplicationCredentials(entryId: string) {
try {
return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId))
.application_credentials_id;
} catch (_err: any) {
// We won't prompt the user to remove credentials
return null;
}
}
private async _removeApplicationCredential(applicationCredentialsId: string) {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_title"
),
text: html`${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_prompt"
)},
<br />
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
)}
<br />
<br />
<a
href="https://www.home-assistant.io/integrations/application_credentials"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
)}
</a>`,
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await deleteApplicationCredential(this.hass, applicationCredentialsId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_error_title"
),
text: err.message,
});
}
}
private async _handleDelete() {
const entryId = this.flow.context.entry_id;
if (!entryId) {
// This shouldn't happen for reauth flows, but handle gracefully
return;
}
const applicationCredentialsId =
await this._fetchApplicationCredentials(entryId);
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: localizeConfigFlowTitle(this.hass.localize, this.flow) }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const result = await deleteConfigEntry(this.hass, entryId);
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
),
});
}
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
this._handleFlowUpdated();
}
static styles = css` static styles = css`
a { a {
text-decoration: none; text-decoration: none;
@@ -191,6 +324,9 @@ export class HaConfigFlowCard extends LitElement {
--mdc-theme-primary: var(--error-color); --mdc-theme-primary: var(--error-color);
--ha-card-border-color: var(--error-color); --ha-card-border-color: var(--error-color);
} }
.warning {
--mdc-theme-text-primary-on-background: var(--error-color);
}
`; `;
} }

View File

@@ -145,13 +145,16 @@ class HaConfigSubEntryRow extends LitElement {
</ha-md-menu-item> </ha-md-menu-item>
` `
: nothing} : nothing}
<ha-md-menu-item @click=${this._handleRenameSub}> <ha-md-menu-item .clickAction=${this._handleRenameSub}>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename" "ui.panel.config.integrations.config_entry.rename"
)} )}
</ha-md-menu-item> </ha-md-menu-item>
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}> <ha-md-menu-item
class="warning"
.clickAction=${this._handleDeleteSub}
>
<ha-svg-icon <ha-svg-icon
slot="start" slot="start"
class="warning" class="warning"
@@ -222,7 +225,7 @@ class HaConfigSubEntryRow extends LitElement {
}); });
} }
private async _handleRenameSub(): Promise<void> { private _handleRenameSub = async (): Promise<void> => {
const newName = await showPromptDialog(this, { const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.common.rename"), title: this.hass.localize("ui.common.rename"),
defaultValue: this.subEntry.title, defaultValue: this.subEntry.title,
@@ -239,9 +242,9 @@ class HaConfigSubEntryRow extends LitElement {
this.subEntry.subentry_id, this.subEntry.subentry_id,
{ title: newName } { title: newName }
); );
} };
private async _handleDeleteSub(): Promise<void> { private _handleDeleteSub = async (): Promise<void> => {
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize( title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title", "ui.panel.config.integrations.config_entry.delete_confirm_title",
@@ -263,7 +266,7 @@ class HaConfigSubEntryRow extends LitElement {
this.entry.entry_id, this.entry.entry_id,
this.subEntry.subentry_id this.subEntry.subentry_id
); );
} };
static styles = css` static styles = css`
.expand-button { .expand-button {

View File

@@ -12,7 +12,10 @@ import "../../../../../components/ha-tab-group";
import "../../../../../components/ha-tab-group-tab"; import "../../../../../components/ha-tab-group-tab";
import type { ZHADevice, ZHAGroup } from "../../../../../data/zha"; import type { ZHADevice, ZHAGroup } from "../../../../../data/zha";
import { fetchBindableDevices, fetchGroups } from "../../../../../data/zha"; import { fetchBindableDevices, fetchGroups } from "../../../../../data/zha";
import { haStyleDialog } from "../../../../../resources/styles"; import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import { sortZHADevices, sortZHAGroups } from "./functions"; import { sortZHADevices, sortZHAGroups } from "./functions";
import type { import type {
@@ -211,11 +214,11 @@ class DialogZHAManageZigbeeDevice extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-surface-position: static; --dialog-surface-position: static;
--dialog-content-position: static; --dialog-content-position: static;
--vertical-align-dialog: flex-start;
} }
.content { .content {
@@ -229,8 +232,9 @@ class DialogZHAManageZigbeeDevice extends LitElement {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: 560px; --mdc-dialog-min-width: 560px;
--mdc-dialog-max-width: 560px; --mdc-dialog-max-width: 560px;
--dialog-surface-margin-top: 40px; --mdc-dialog-max-height: calc(
--mdc-dialog-max-height: calc(100% - 72px); 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
} }
} }

View File

@@ -295,7 +295,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
color: color:
route.route_status === "Active" route.route_status === "Active"
? primaryColor ? primaryColor
: style.getPropertyValue("--disabled-color"), : style.getPropertyValue("--dark-primary-color"),
type: ["Child", "Parent"].includes(neighbor.relationship) type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid" ? "solid"
: "dotted", : "dotted",
@@ -335,7 +335,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
symbolSize: 5, symbolSize: 5,
lineStyle: { lineStyle: {
width: 1, width: 1,
color: style.getPropertyValue("--disabled-color"), color: style.getPropertyValue("--dark-primary-color"),
type: "dotted", type: "dotted",
}, },
ignoreForceLayout: true, ignoreForceLayout: true,

View File

@@ -0,0 +1,223 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { relativeTime } from "../../../common/datetime/relative_time";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-switch";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable";
@customElement("dialog-labs-preview-feature-enable")
export class DialogLabsPreviewFeatureEnable
extends LitElement
implements HassDialog<LabsPreviewFeatureEnableDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LabsPreviewFeatureEnableDialogParams;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackup = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(
params: LabsPreviewFeatureEnableDialogParams
): Promise<void> {
this._params = params;
this._createBackup = false;
await this._fetchBackupConfig();
}
public closeDialog(): boolean {
this._dialog?.close();
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._backupConfig = undefined;
this._createBackup = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _fetchBackupConfig() {
try {
const { config } = await fetchBackupConfig(this.hass);
this._backupConfig = config;
// Default to enabled if automatic backups are configured, disabled otherwise
this._createBackup =
config.automatic_backups_configured &&
!!config.create_backup.password &&
config.create_backup.agent_ids.length > 0;
} catch {
// User will get manual backup option if fetch fails
this._createBackup = false;
}
}
private _computeCreateBackupTexts():
| { title: string; description?: string }
| undefined {
if (
!this._backupConfig ||
!this._backupConfig.automatic_backups_configured ||
!this._backupConfig.create_backup.password ||
this._backupConfig.create_backup.agent_ids.length === 0
) {
return {
title: this.hass.localize("ui.panel.config.labs.create_backup.manual"),
description: this.hass.localize(
"ui.panel.config.labs.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
.last_completed_automatic_backup
? new Date(this._backupConfig.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this.hass.localize("ui.panel.config.labs.create_backup.automatic"),
description: lastAutomaticBackupDate
? this.hass.localize(
"ui.panel.config.labs.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this.hass.locale,
now,
true
),
}
)
: this.hass.localize(
"ui.panel.config.labs.create_backup.automatic_description_none"
),
};
}
private _createBackupChanged(ev: Event): void {
this._createBackup = (ev.target as HaSwitch).checked;
}
private _handleCancel(): void {
this.closeDialog();
}
private _handleConfirm(): void {
if (this._params) {
this._params.onConfirm(this._createBackup);
}
this.closeDialog();
}
protected render() {
if (!this._params) {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<span slot="headline">
${this.hass.localize("ui.panel.config.labs.enable_title")}
</span>
<div slot="content">
<p>
${this.hass.localize(
`component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation`
) || this.hass.localize("ui.panel.config.labs.enable_confirmation")}
</p>
</div>
<div slot="actions">
${createBackupTexts
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">${createBackupTexts.title}</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
`
: nothing}
<div>
<ha-button appearance="plain" @click=${this._handleCancel}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
appearance="filled"
variant="brand"
@click=${this._handleConfirm}
>
${this.hass.localize("ui.panel.config.labs.enable")}
</ha-button>
</div>
</div>
</ha-md-dialog>
`;
}
static readonly styles = css`
ha-md-dialog {
--dialog-content-padding: var(--ha-space-6);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
div[slot="actions"] {
display: flex;
flex-direction: column;
padding: 0;
}
ha-md-list {
background: none;
--md-list-item-leading-space: var(--ha-space-6);
--md-list-item-trailing-space: var(--ha-space-6);
margin: 0;
padding: 0;
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
div[slot="actions"] > div {
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
padding: var(--ha-space-4) var(--ha-space-6);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-labs-preview-feature-enable": DialogLabsPreviewFeatureEnable;
}
}

View File

@@ -0,0 +1,113 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-md-dialog";
import "../../../components/ha-spinner";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { LabsProgressDialogParams } from "./show-dialog-labs-progress";
@customElement("dialog-labs-progress")
export class DialogLabsProgress
extends LitElement
implements HassDialog<LabsProgressDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LabsProgressDialogParams;
@state() private _open = false;
public async showDialog(params: LabsProgressDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _handleClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog
.open=${this._open}
hideActions
scrimClickAction=""
escapeKeyAction=""
@closed=${this._handleClosed}
>
<div slot="content">
<div class="summary">
<ha-spinner></ha-spinner>
<div class="content">
<p class="heading">
${this.hass.localize(
"ui.panel.config.labs.progress.creating_backup"
)}
</p>
<p class="description">
${this.hass.localize(
this._params.enabled
? "ui.panel.config.labs.progress.backing_up_before_enabling"
: "ui.panel.config.labs.progress.backing_up_before_disabling"
)}
</p>
</div>
</div>
</div>
</ha-md-dialog>
`;
}
static readonly styles = css`
ha-md-dialog {
--dialog-content-padding: var(--ha-space-6);
}
.summary {
display: flex;
flex-direction: row;
column-gap: var(--ha-space-4);
align-items: center;
justify-content: center;
padding: var(--ha-space-4) 0;
}
ha-spinner {
--ha-spinner-size: 60px;
flex-shrink: 0;
}
.content {
flex: 1;
min-width: 0;
}
.heading {
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
margin: 0 0 var(--ha-space-1);
}
.description {
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-labs-progress": DialogLabsProgress;
}
}

View File

@@ -0,0 +1,550 @@
import { mdiFlask, mdiHelpCircle, mdiOpenInNew } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { domainToName } from "../../../data/integration";
import {
labsUpdatePreviewFeature,
subscribeLabFeatures,
} from "../../../data/labs";
import type { LabPreviewFeature } from "../../../data/labs";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { brandsUrl } from "../../../util/brands-url";
import { showToast } from "../../../util/toast";
import { documentationUrl } from "../../../util/documentation-url";
import { haStyle } from "../../../resources/styles";
import { showLabsPreviewFeatureEnableDialog } from "./show-dialog-labs-preview-feature-enable";
import {
showLabsProgressDialog,
closeLabsProgressDialog,
} from "./show-dialog-labs-progress";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import "../../../components/ha-switch";
import "../../../layouts/hass-subpage";
@customElement("ha-config-labs")
class HaConfigLabs extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _preview_features: LabPreviewFeature[] = [];
@state() private _highlightedPreviewFeature?: string;
private _sortedPreviewFeatures = memoizeOne(
(localize: LocalizeFunc, features: LabPreviewFeature[]) =>
// Sort by localized integration name alphabetically
[...features].sort((a, b) =>
domainToName(localize, a.domain).localeCompare(
domainToName(localize, b.domain)
)
)
);
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass.connection, (features) => {
// Load title translations for integrations with preview features
const domains = [...new Set(features.map((f) => f.domain))];
this.hass.loadBackendTranslation("title", domains);
this._preview_features = features;
}),
];
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
// Load preview_features translations
this.hass.loadBackendTranslation("preview_features");
this._handleUrlParams();
}
private _handleUrlParams(): void {
// Check for feature parameters in URL
const domain = extractSearchParam("domain");
const previewFeature = extractSearchParam("preview_feature");
if (domain && previewFeature) {
const previewFeatureId = `${domain}.${previewFeature}`;
this._highlightedPreviewFeature = previewFeatureId;
// Wait for next render to ensure cards are in DOM
this.updateComplete.then(() => {
this._scrollToPreviewFeature(previewFeatureId);
});
}
}
protected render() {
const sortedFeatures = this._sortedPreviewFeatures(
this.hass.localize,
this._preview_features
);
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.labs.caption")}
>
${sortedFeatures.length
? html`
<a
slot="toolbar-icon"
href=${documentationUrl(this.hass, "/integrations/labs/")}
target="_blank"
rel="noopener noreferrer"
.title=${this.hass.localize("ui.common.help")}
>
<ha-icon-button
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
`
: nothing}
<div class="content">
${!sortedFeatures.length
? html`
<div class="empty">
<ha-svg-icon .path=${mdiFlask}></ha-svg-icon>
<h1>
${this.hass.localize("ui.panel.config.labs.empty.title")}
</h1>
${this.hass.localize(
"ui.panel.config.labs.empty.description"
)}
<a
href=${documentationUrl(this.hass, "/integrations/labs/")}
target="_blank"
rel="noopener noreferrer"
>
${this.hass.localize("ui.panel.config.labs.learn_more")}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</div>
`
: html`
<ha-card outlined>
<div class="card-content intro-card">
<h1>
${this.hass.localize("ui.panel.config.labs.intro_title")}
</h1>
<p class="intro-text">
${this.hass.localize(
"ui.panel.config.labs.intro_description"
)}
</p>
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.labs.intro_warning"
)}
</ha-alert>
</div>
</ha-card>
${sortedFeatures.map((preview_feature) =>
this._renderPreviewFeature(preview_feature)
)}
`}
</div>
</hass-subpage>
`;
}
private _renderPreviewFeature(
preview_feature: LabPreviewFeature
): TemplateResult {
const featureName = this.hass.localize(
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.name`
);
const description = this.hass.localize(
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.description`
);
const integrationName = domainToName(
this.hass.localize,
preview_feature.domain
);
const integrationNameWithCustomLabel = !preview_feature.is_built_in
? `${integrationName}${this.hass.localize("ui.panel.config.labs.custom_integration")}`
: integrationName;
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
// Build description with learn more link if available
const descriptionWithLink = preview_feature.learn_more_url
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
: description;
return html`
<ha-card
outlined
data-feature-id=${previewFeatureId}
class=${isHighlighted ? "highlighted" : ""}
>
<div class="card-content">
<div class="card-header">
<img
alt=""
src=${brandsUrl({
domain: preview_feature.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<div class="feature-title">
<span class="integration-name"
>${integrationNameWithCustomLabel}</span
>
<h2>${featureName}</h2>
</div>
</div>
<ha-markdown .content=${descriptionWithLink} breaks></ha-markdown>
</div>
<div class="card-actions">
<div>
${preview_feature.feedback_url
? html`
<ha-button
appearance="plain"
href=${preview_feature.feedback_url}
target="_blank"
rel="noopener noreferrer"
>
${this.hass.localize(
"ui.panel.config.labs.provide_feedback"
)}
</ha-button>
`
: nothing}
${preview_feature.report_issue_url
? html`
<ha-button
appearance="plain"
href=${preview_feature.report_issue_url}
target="_blank"
rel="noopener noreferrer"
>
${this.hass.localize("ui.panel.config.labs.report_issue")}
</ha-button>
`
: nothing}
</div>
<ha-button
appearance="filled"
.variant=${preview_feature.enabled ? "danger" : "brand"}
@click=${this._handleToggle}
.preview_feature=${preview_feature}
>
${this.hass.localize(
preview_feature.enabled
? "ui.panel.config.labs.disable"
: "ui.panel.config.labs.enable"
)}
</ha-button>
</div>
</ha-card>
`;
}
private _scrollToPreviewFeature(previewFeatureId: string): void {
const card = this.shadowRoot?.querySelector(
`[data-feature-id="${previewFeatureId}"]`
) as HTMLElement;
if (card) {
card.scrollIntoView({ behavior: "smooth", block: "center" });
// Clear highlight after animation
setTimeout(() => {
this._highlightedPreviewFeature = undefined;
}, 3000);
}
}
private async _handleToggle(ev: Event): Promise<void> {
const buttonEl = ev.currentTarget as HTMLElement & {
preview_feature: LabPreviewFeature;
};
const preview_feature = buttonEl.preview_feature;
const enabled = !preview_feature.enabled;
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
if (enabled) {
// Show custom enable dialog with backup option
showLabsPreviewFeatureEnableDialog(this, {
preview_feature,
previewFeatureId,
onConfirm: async (shouldCreateBackup) => {
await this._performToggle(
previewFeatureId,
enabled,
shouldCreateBackup
);
},
});
return;
}
// Show simple confirmation dialog for disable
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.labs.disable_title"),
text:
this.hass.localize(
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.disable_confirmation`
) || this.hass.localize("ui.panel.config.labs.disable_confirmation"),
confirmText: this.hass.localize("ui.panel.config.labs.disable"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await this._performToggle(previewFeatureId, enabled, false);
}
private async _performToggle(
previewFeatureId: string,
enabled: boolean,
createBackup: boolean
): Promise<void> {
if (createBackup) {
showLabsProgressDialog(this, { enabled });
}
const parts = previewFeatureId.split(".", 2);
if (parts.length !== 2) {
showToast(this, {
message: this.hass.localize("ui.common.unknown_error"),
});
return;
}
const [domain, preview_feature] = parts;
try {
await labsUpdatePreviewFeature(
this.hass,
domain,
preview_feature,
enabled,
createBackup
);
} catch (err: any) {
if (createBackup) {
closeLabsProgressDialog();
}
const errorMessage =
err?.message || this.hass.localize("ui.common.unknown_error");
showToast(this, {
message: this.hass.localize(
enabled
? "ui.panel.config.labs.enable_failed"
: "ui.panel.config.labs.disable_failed",
{ error: errorMessage }
),
});
return;
}
// Close dialog before showing success toast
if (createBackup) {
closeLabsProgressDialog();
}
// Show success toast - collection will auto-update via labs_updated event
showToast(this, {
message: this.hass.localize(
enabled
? "ui.panel.config.labs.enabled_success"
: "ui.panel.config.labs.disabled_success"
),
});
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.content {
max-width: 800px;
margin: 0 auto;
padding: var(--ha-space-4);
min-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
}
.content:has(.empty) {
justify-content: center;
}
ha-card {
margin-bottom: var(--ha-space-4);
position: relative;
transition: box-shadow 0.3s ease;
}
ha-card.highlighted {
animation: highlight-fade 2.5s ease-out forwards;
}
@keyframes highlight-fade {
0% {
box-shadow:
0 0 0 var(--ha-border-width-md) var(--primary-color),
0 0 var(--ha-shadow-blur-lg) rgba(var(--rgb-primary-color), 0.4);
}
100% {
box-shadow:
0 0 0 var(--ha-border-width-md) transparent,
0 0 0 transparent;
}
}
/* Intro card */
.intro-card {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.intro-card h1 {
margin: 0;
}
.intro-text {
margin: 0 0 var(--ha-space-3);
}
/* Feature cards */
.card-content {
padding: var(--ha-space-4);
}
.card-header {
display: flex;
gap: var(--ha-space-3);
margin-bottom: var(--ha-space-4);
align-items: flex-start;
}
.card-header img {
width: 38px;
height: 38px;
flex-shrink: 0;
margin-top: 2px;
}
.feature-title {
flex: 1;
min-width: 0;
}
.feature-title h2 {
margin: 0;
line-height: 1.3;
}
.integration-name {
display: block;
margin-bottom: 2px;
font-size: 14px;
color: var(--secondary-text-color);
}
/* Empty state */
.empty {
max-width: 500px;
margin: 0 auto;
padding: var(--ha-space-12) var(--ha-space-4);
text-align: center;
}
.empty ha-svg-icon {
width: 120px;
height: 120px;
color: var(--secondary-text-color);
opacity: 0.3;
}
.empty h1 {
margin: var(--ha-space-6) 0 var(--ha-space-4);
}
.empty p {
margin: 0 0 var(--ha-space-6);
font-size: 16px;
line-height: 24px;
color: var(--secondary-text-color);
}
.empty a {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.empty a:hover {
text-decoration: underline;
}
.empty a:focus-visible {
outline: var(--ha-border-width-md) solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
.empty a ha-svg-icon {
width: 16px;
height: 16px;
opacity: 1;
}
/* Card actions */
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2);
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
.card-actions > div {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-labs": HaConfigLabs;
}
}

View File

@@ -0,0 +1,22 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { LabPreviewFeature } from "../../../data/labs";
export interface LabsPreviewFeatureEnableDialogParams {
preview_feature: LabPreviewFeature;
previewFeatureId: string;
onConfirm: (createBackup: boolean) => void;
}
export const loadLabsPreviewFeatureEnableDialog = () =>
import("./dialog-labs-preview-feature-enable");
export const showLabsPreviewFeatureEnableDialog = (
element: HTMLElement,
params: LabsPreviewFeatureEnableDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-labs-preview-feature-enable",
dialogImport: loadLabsPreviewFeatureEnableDialog,
dialogParams: params,
});
};

View File

@@ -0,0 +1,22 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { closeDialog } from "../../../dialogs/make-dialog-manager";
export interface LabsProgressDialogParams {
enabled: boolean;
}
export const loadLabsProgressDialog = () => import("./dialog-labs-progress");
export const showLabsProgressDialog = (
element: HTMLElement,
dialogParams: LabsProgressDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-labs-progress",
dialogImport: loadLabsProgressDialog,
dialogParams,
});
};
export const closeLabsProgressDialog = () =>
closeDialog("dialog-labs-progress");

View File

@@ -4,18 +4,20 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify"; import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-button";
import { createCloseHeading } from "../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { SchemaUnion } from "../../../../components/ha-form/types";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type { import type {
LovelaceDashboard, LovelaceDashboard,
LovelaceDashboardCreateParams, LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams, LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard"; } from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL, setDefaultPanel } from "../../../../data/panel"; import { DEFAULT_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles"; import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail"; import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
@customElement("dialog-lovelace-dashboard-detail") @customElement("dialog-lovelace-dashboard-detail")
@@ -59,7 +61,8 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params || !this._data) { if (!this._params || !this._data) {
return nothing; return nothing;
} }
const defaultPanelUrlPath = this.hass.defaultPanel; const defaultPanelUrlPath =
this.hass.systemData?.default_panel || DEFAULT_PANEL;
const titleInvalid = !this._data.title || !this._data.title.trim(); const titleInvalid = !this._data.title || !this._data.title.trim();
return html` return html`
@@ -251,15 +254,38 @@ export class DialogLovelaceDashboardDetail extends LitElement {
}; };
} }
private _toggleDefault() { private async _toggleDefault() {
const urlPath = this._params?.urlPath; const urlPath = this._params?.urlPath;
if (!urlPath) { if (!urlPath) {
return; return;
} }
setDefaultPanel(
this, const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
urlPath === this.hass.defaultPanel ? DEFAULT_PANEL : urlPath // Add warning dialog to saying that this will change the default dashboard for all users
); const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_title"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_text"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
default_panel: urlPath === defaultPanel ? undefined : urlPath,
});
} }
private async _updateDashboard() { private async _updateDashboard() {

View File

@@ -45,6 +45,7 @@ import {
fetchDashboards, fetchDashboards,
updateDashboard, updateDashboard,
} from "../../../../data/lovelace/dashboard"; } from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-loading-screen"; import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table"; import "../../../../layouts/hass-tabs-subpage-data-table";
@@ -286,7 +287,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
); );
private _getItems = memoize( private _getItems = memoize(
(dashboards: LovelaceDashboard[], defaultUrlPath: string) => { (dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
const defaultMode = ( const defaultMode = (
this.hass.panels?.lovelace?.config as LovelacePanelConfig this.hass.panels?.lovelace?.config as LovelacePanelConfig
).mode; ).mode;
@@ -403,6 +404,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
return html` <hass-loading-screen></hass-loading-screen> `; return html` <hass-loading-screen></hass-loading-screen> `;
} }
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
@@ -416,7 +419,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._dashboards, this._dashboards,
this.hass.localize this.hass.localize
)} )}
.data=${this._getItems(this._dashboards, this.hass.defaultPanel)} .data=${this._getItems(this._dashboards, defaultPanel)}
.initialGroupColumn=${this._activeGrouping} .initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed} .initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting} .initialSorting=${this._activeSorting}

View File

@@ -1112,6 +1112,9 @@ ${rejected
private async _delete(scene: SceneEntity): Promise<void> { private async _delete(scene: SceneEntity): Promise<void> {
if (scene.attributes.id) { if (scene.attributes.id) {
await deleteScene(this.hass, scene.attributes.id); await deleteScene(this.hass, scene.attributes.id);
this._selected = this._selected.filter(
(entityId) => entityId !== scene.entity_id
);
} }
} }

View File

@@ -1265,6 +1265,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
display: block; display: block;
margin-bottom: 24px; margin-bottom: 24px;
} }
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
ha-fab.dirty { ha-fab.dirty {
bottom: 0; bottom: 0;
} }

View File

@@ -1183,6 +1183,9 @@ ${rejected
); );
if (entry) { if (entry) {
await deleteScript(this.hass, entry.unique_id); await deleteScript(this.hass, entry.unique_id);
this._selected = this._selected.filter(
(entityId) => entityId !== script.entity_id
);
} }
} catch (err: any) { } catch (err: any) {
await showAlertDialog(this, { await showAlertDialog(this, {

View File

@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { documentationUrl } from "../../../util/documentation-url";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button"; import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog"; import { createCloseHeading } from "../../../components/ha-dialog";
@@ -14,8 +15,6 @@ import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { TagDetailDialogParams } from "./show-dialog-tag-detail"; import type { TagDetailDialogParams } from "./show-dialog-tag-detail";
const TAG_BASE = "https://www.home-assistant.io/tag/";
@customElement("dialog-tag-detail") @customElement("dialog-tag-detail")
class DialogTagDetail class DialogTagDetail
extends LitElement extends LitElement
@@ -122,7 +121,7 @@ class DialogTagDetail
</div> </div>
<div id="qr"> <div id="qr">
<ha-qr-code <ha-qr-code
.data=${`${TAG_BASE}${this._params!.entry!.id}`} .data=${`${documentationUrl(this.hass, "/tag/")}${this._params!.entry!.id}`}
center-image="/static/icons/favicon-192x192.png" center-image="/static/icons/favicon-192x192.png"
error-correction-level="quartile" error-correction-level="quartile"
scale="5" scale="5"

View File

@@ -245,6 +245,7 @@ class HaPanelDevStateRenderer extends LitElement {
:host([virtualize]) { :host([virtualize]) {
display: block; display: block;
height: 100%; height: 100%;
overflow: auto;
} }
.entities { .entities {

View File

@@ -1,4 +1,4 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { mdiPencil, mdiDownload } from "@mdi/js"; import { mdiPencil, mdiDownload } from "@mdi/js";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@@ -6,6 +6,7 @@ import "../../components/ha-menu-button";
import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item"; import "../../components/ha-list-item";
import "../../components/ha-top-app-bar-fixed"; import "../../components/ha-top-app-bar-fixed";
import "../../components/ha-alert";
import type { LovelaceConfig } from "../../data/lovelace/config/types"; import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
@@ -21,6 +22,7 @@ import type {
GasSourceTypeEnergyPreference, GasSourceTypeEnergyPreference,
WaterSourceTypeEnergyPreference, WaterSourceTypeEnergyPreference,
DeviceConsumptionEnergyPreference, DeviceConsumptionEnergyPreference,
EnergyCollection,
} from "../../data/energy"; } from "../../data/energy";
import { import {
computeConsumptionData, computeConsumptionData,
@@ -30,13 +32,28 @@ import {
import { fileDownload } from "../../util/file_download"; import { fileDownload } from "../../util/file_download";
import type { StatisticValue } from "../../data/recorder"; import type { StatisticValue } from "../../data/recorder";
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
const ENERGY_LOVELACE_CONFIG: LovelaceConfig = { const ENERGY_LOVELACE_CONFIG: LovelaceConfig = {
views: [ views: [
{ {
strategy: { strategy: {
type: "energy", type: "energy-overview",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
}, },
}, },
{
strategy: {
type: "energy-electricity",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
path: "electricity",
},
{
type: "panel",
path: "setup",
cards: [{ type: "custom:energy-setup-wizard-card" }],
},
], ],
}; };
@@ -46,13 +63,30 @@ class PanelEnergy extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false; @property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace; @state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search); @state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) { @state() private _error?: string;
@property({ attribute: false }) public route?: {
path: string;
prefix: string;
};
private _energyCollection?: EnergyCollection;
get _viewPath(): string | undefined {
const viewPath: string | undefined = this.route!.path.split("/")[1];
return viewPath ? decodeURI(viewPath) : undefined;
}
public connectedCallback() {
super.connectedCallback();
this._loadPrefs();
}
public async willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace"); this.hass.loadFragmentTranslation("lovelace");
} }
@@ -62,22 +96,71 @@ class PanelEnergy extends LitElement {
const oldHass = changedProps.get("hass") as this["hass"]; const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) { if (oldHass?.locale !== this.hass.locale) {
this._setLovelace(); this._setLovelace();
} } else if (oldHass && oldHass.localize !== this.hass.localize) {
if (oldHass && oldHass.localize !== this.hass.localize) {
this._reloadView(); this._reloadView();
} }
} }
private async _loadPrefs() {
if (this._viewPath === "setup") {
await import("./cards/energy-setup-wizard-card");
} else {
this._energyCollection = getEnergyDataCollection(this.hass, {
key: DEFAULT_ENERGY_COLLECTION_KEY,
});
try {
// Have to manually refresh here as we don't want to subscribe yet
await this._energyCollection.refresh();
} catch (err: any) {
if (err.code === "not_found") {
navigate("/energy/setup");
}
this._error = err.message;
return;
}
const prefs = this._energyCollection.prefs!;
if (
prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0
) {
// No energy sources available, start from scratch
navigate("/energy/setup");
}
}
}
private _back(ev) { private _back(ev) {
ev.stopPropagation(); ev.stopPropagation();
goBack(); goBack();
} }
protected render(): TemplateResult { protected render() {
if (!this._energyCollection?.prefs) {
// Still loading
return html`<div class="centered">
<ha-spinner size="large"></ha-spinner>
</div>`;
}
const { prefs } = this._energyCollection;
const isSingleView = prefs.energy_sources.every((source) =>
["grid", "solar", "battery"].includes(source.type)
);
let viewPath = this._viewPath;
if (isSingleView) {
// if only electricity sources, show electricity view directly
viewPath = "electricity";
}
const viewIndex = Math.max(
ENERGY_LOVELACE_CONFIG.views.findIndex((view) => view.path === viewPath),
0
);
const showBack =
this._searchParms.has("historyBack") || (!isSingleView && viewIndex > 0);
return html` return html`
<div class="header"> <div class="header">
<div class="toolbar"> <div class="toolbar">
${this._searchParms.has("historyBack") ${showBack
? html` ? html`
<ha-icon-button-arrow-prev <ha-icon-button-arrow-prev
@click=${this._back} @click=${this._back}
@@ -99,7 +182,7 @@ class PanelEnergy extends LitElement {
<hui-energy-period-selector <hui-energy-period-selector
.hass=${this.hass} .hass=${this.hass}
collection-key="energy_dashboard" .collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
> >
${this.hass.user?.is_admin ${this.hass.user?.is_admin
? html` <ha-list-item ? html` <ha-list-item
@@ -127,12 +210,21 @@ class PanelEnergy extends LitElement {
.hass=${this.hass} .hass=${this.hass}
@reload-energy-panel=${this._reloadView} @reload-energy-panel=${this._reloadView}
> >
<hui-view ${this._error
? html`<div class="centered">
<ha-alert alert-type="error">
An error occurred while fetching your energy preferences:
${this._error}
</ha-alert>
</div>`
: this._lovelace
? html`<hui-view
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
.lovelace=${this._lovelace} .lovelace=${this._lovelace}
.index=${this._viewIndex} .index=${viewIndex}
></hui-view> ></hui-view>`
: nothing}
</hui-view-container> </hui-view-container>
`; `;
} }
@@ -160,9 +252,7 @@ class PanelEnergy extends LitElement {
private async _dumpCSV(ev) { private async _dumpCSV(ev) {
ev.stopPropagation(); ev.stopPropagation();
const energyData = getEnergyDataCollection(this.hass, { const energyData = this._energyCollection!;
key: "energy_dashboard",
});
if (!energyData.prefs || !energyData.state.stats) { if (!energyData.prefs || !energyData.state.stats) {
return; return;
@@ -459,11 +549,11 @@ class PanelEnergy extends LitElement {
} }
private _reloadView() { private _reloadView() {
// Force strategy to be re-run by make a copy of the view // Force strategy to be re-run by making a copy of the view
const config = this._lovelace!.config; const config = this._lovelace!.config;
this._lovelace = { this._lovelace = {
...this._lovelace!, ...this._lovelace!,
config: { ...config, views: [{ ...config.views[0] }] }, config: { ...config, views: config.views.map((view) => ({ ...view })) },
}; };
} }
@@ -565,6 +655,13 @@ class PanelEnergy extends LitElement {
flex: 1 1 100%; flex: 1 1 100%;
max-width: 100%; max-width: 100%;
} }
.centered {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
`, `,
]; ];
} }

View File

@@ -1,55 +1,35 @@
import { ReactiveElement } from "lit"; import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
EnergyPreferences, import { getEnergyDataCollection } from "../../../data/energy";
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { getEnergyPreferences } from "../../../data/energy";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
const setupWizard = async (): Promise<LovelaceViewConfig> => { @customElement("energy-electricity-view-strategy")
await import("../cards/energy-setup-wizard-card"); export class EnergyElectricityViewStrategy extends ReactiveElement {
return {
type: "panel",
cards: [
{
type: "custom:energy-setup-wizard-card",
},
],
};
};
@customElement("energy-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
static async generate( static async generate(
_config: LovelaceStrategyConfig, _config: LovelaceStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
): Promise<LovelaceViewConfig> { ): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = { cards: [] }; const view: LovelaceViewConfig = { cards: [] };
let prefs: EnergyPreferences; const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
try { const energyCollection = getEnergyDataCollection(hass, {
prefs = await getEnergyPreferences(hass); key: collectionKey,
} catch (err: any) {
if (err.code === "not_found") {
return setupWizard();
}
view.cards!.push({
type: "markdown",
content: `An error occurred while fetching your energy preferences: ${err.message}.`,
}); });
return view; const prefs = energyCollection.prefs;
}
// No energy sources available, start from scratch // No energy sources available
if ( if (
prefs!.device_consumption.length === 0 && !prefs ||
prefs!.energy_sources.length === 0 (prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0)
) { ) {
return setupWizard(); return view;
} }
view.type = "sidebar"; view.type = "sidebar";
@@ -63,12 +43,17 @@ export class EnergyViewStrategy extends ReactiveElement {
const hasSolar = prefs.energy_sources.some( const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar" (source) => source.type === "solar"
); );
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some( const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery" (source) => source.type === "battery"
); );
const hasWater = prefs.energy_sources.some( const hasPowerSources = prefs.energy_sources.find(
(source) => source.type === "water" (source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerDevices = prefs.device_consumption.find(
(device) => device.stat_rate
); );
view.cards!.push({ view.cards!.push({
@@ -76,6 +61,24 @@ export class EnergyViewStrategy extends ReactiveElement {
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
}); });
if (hasPowerSources) {
if (hasPowerDevices) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
});
}
// Only include if we have a grid or battery. // Only include if we have a grid or battery.
if (hasGrid || hasBattery) { if (hasGrid || hasBattery) {
view.cards!.push({ view.cards!.push({
@@ -94,24 +97,6 @@ export class EnergyViewStrategy extends ReactiveElement {
}); });
} }
// Only include if we have a gas source.
if (hasGas) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
collection_key: "energy_dashboard",
});
}
// Only include if we have a water source.
if (hasWater) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
type: "energy-water-graph",
collection_key: "energy_dashboard",
});
}
// Only include if we have a grid or battery. // Only include if we have a grid or battery.
if (hasGrid || hasBattery) { if (hasGrid || hasBattery) {
view.cards!.push({ view.cards!.push({
@@ -122,13 +107,14 @@ export class EnergyViewStrategy extends ReactiveElement {
}); });
} }
if (hasGrid || hasSolar || hasGas || hasWater || hasBattery) { if (hasGrid || hasSolar || hasBattery) {
view.cards!.push({ view.cards!.push({
title: hass.localize( title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title" "ui.panel.energy.cards.energy_sources_table_title"
), ),
type: "energy-sources-table", type: "energy-sources-table",
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
types: ["grid", "solar", "battery"],
}); });
} }
@@ -170,20 +156,6 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have at least 1 device in the config. // Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) { if (prefs.device_consumption.length) {
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard",
});
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: "energy_dashboard",
});
const showFloorsNAreas = !prefs.device_consumption.some( const showFloorsNAreas = !prefs.device_consumption.some(
(d) => d.included_in_stat (d) => d.included_in_stat
); );
@@ -194,6 +166,20 @@ export class EnergyViewStrategy extends ReactiveElement {
group_by_floor: showFloorsNAreas, group_by_floor: showFloorsNAreas,
group_by_area: showFloorsNAreas, group_by_area: showFloorsNAreas,
}); });
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: "energy_dashboard",
});
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard",
});
} }
return view; return view;
@@ -202,6 +188,6 @@ export class EnergyViewStrategy extends ReactiveElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"energy-view-strategy": EnergyViewStrategy; "energy-electricity-view-strategy": EnergyElectricityViewStrategy;
} }
} }

View File

@@ -0,0 +1,218 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
@customElement("energy-overview-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [],
dense_section_placement: true,
max_columns: 2,
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
const prefs = energyCollection.prefs;
// No energy sources available
if (
!prefs ||
(prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0)
) {
return view;
}
const hasGrid = prefs.energy_sources.find(
(source) =>
source.type === "grid" &&
(source.flow_from?.length || source.flow_to?.length)
) as GridSourceTypeEnergyPreference;
const hasReturn = hasGrid && hasGrid.flow_to.length > 0;
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
);
const hasWater = prefs.energy_sources.some(
(source) => source.type === "water"
);
const hasPowerSources = prefs.energy_sources.find(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerDevices = prefs.device_consumption.find(
(device) => device.stat_rate
);
const overviewSection: LovelaceSectionConfig = {
type: "grid",
column_span: 24,
cards: [],
};
if (hasPowerSources && hasPowerDevices) {
overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
// Only include if we have a grid or battery.
if (hasGrid || hasBattery) {
overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
type: "energy-distribution",
collection_key: collectionKey,
});
}
if (hasGrid || hasSolar || hasBattery || hasGas || hasWater) {
overviewSection.cards!.push({
type: "energy-sources-table",
collection_key: collectionKey,
});
}
view.sections!.push(overviewSection);
const electricitySection: LovelaceSectionConfig = {
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.electricity"),
tap_action: {
action: "navigate",
navigation_path: "/energy/electricity",
},
},
],
};
if (hasPowerSources) {
electricitySection.cards!.push({
type: "power-sources-graph",
collection_key: collectionKey,
});
}
if (prefs!.device_consumption.length > 3) {
electricitySection.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_top_consumers_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
max_devices: 3,
modes: ["bar"],
});
} else if (hasGrid) {
const gauges: LovelaceCardConfig[] = [];
// Only include if we have a grid source & return.
if (hasReturn) {
gauges.push({
type: "energy-grid-neutrality-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-carbon-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
// Only include if we have a solar source.
if (hasSolar) {
if (hasReturn) {
gauges.push({
type: "energy-solar-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-self-sufficiency-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
electricitySection.cards!.push({
type: "grid",
columns: 2,
square: false,
cards: gauges,
});
}
view.sections!.push(electricitySection);
if (hasGas) {
view.sections!.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.gas"),
},
{
title: hass.localize(
"ui.panel.energy.cards.energy_gas_graph_title"
),
type: "energy-gas-graph",
collection_key: collectionKey,
},
],
});
}
if (hasWater) {
view.sections!.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.water"),
},
{
title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title"
),
type: "energy-water-graph",
collection_key: collectionKey,
},
],
});
}
return view;
}
}
declare global {
interface HTMLElementTagNameMap {
"energy-overview-view-strategy": EnergyViewStrategy;
}
}

View File

@@ -0,0 +1,151 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-entities-picker";
import "../../../components/ha-button";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-wa-dialog";
import type { HomeFrontendSystemData } from "../../../data/frontend";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
@customElement("dialog-edit-home")
export class DialogEditHome
extends LitElement
implements HassDialog<EditHomeDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EditHomeDialogParams;
@state() private _config?: HomeFrontendSystemData;
@state() private _open = false;
@state() private _submitting = false;
public showDialog(params: EditHomeDialogParams): void {
this._params = params;
this._config = { ...params.config };
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._config = undefined;
this._submitting = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
.headerTitle=${this.hass.localize("ui.panel.home.editor.title")}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize("ui.panel.home.editor.description")}
</p>
<ha-entities-picker
autofocus
.hass=${this.hass}
.value=${this._config?.favorite_entities || []}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
)}
.placeholder=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
.helper=${this.hass.localize(
"ui.panel.home.editor.favorite_entities_helper"
)}
reorder
allow-custom-entity
@value-changed=${this._favoriteEntitiesChanged}
></ha-entities-picker>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
private _favoriteEntitiesChanged(ev: CustomEvent): void {
const entities = ev.detail.value as string[];
this._config = {
...this._config,
favorite_entities: entities.length > 0 ? entities : undefined,
};
}
private async _save(): Promise<void> {
if (!this._params || !this._config) {
return;
}
this._submitting = true;
try {
await this._params.saveConfig(this._config);
this.closeDialog();
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Failed to save home configuration:", err);
} finally {
this._submitting = false;
}
}
static styles = [
haStyleDialog,
css`
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-6);
}
.description {
margin: 0 0 var(--ha-space-4) 0;
color: var(--secondary-text-color);
}
ha-entities-picker {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-home": DialogEditHome;
}
}

View File

@@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HomeFrontendSystemData } from "../../../data/frontend";
export interface EditHomeDialogParams {
config: HomeFrontendSystemData;
saveConfig: (config: HomeFrontendSystemData) => Promise<void>;
}
export const loadEditHomeDialog = () => import("./dialog-edit-home");
export const showEditHomeDialog = (
element: HTMLElement,
params: EditHomeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-home",
dialogImport: loadEditHomeDialog,
dialogParams: params,
});
};

View File

@@ -3,18 +3,18 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal"; import { deepEqual } from "../../common/util/deep-equal";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
type HomeFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types"; import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import type { HomeAssistant, PanelInfo, Route } from "../../types"; import type { HomeAssistant, PanelInfo, Route } from "../../types";
import { showToast } from "../../util/toast";
import "../lovelace/hui-root"; import "../lovelace/hui-root";
import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy"; import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types"; import type { Lovelace } from "../lovelace/types";
import { showAlertDialog } from "../lovelace/custom-card-helpers"; import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
const HOME_LOVELACE_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",
},
};
@customElement("ha-panel-home") @customElement("ha-panel-home")
class PanelHome extends LitElement { class PanelHome extends LitElement {
@@ -28,12 +28,14 @@ class PanelHome extends LitElement {
@state() private _lovelace?: Lovelace; @state() private _lovelace?: Lovelace;
@state() private _config: FrontendSystemData["home"] = {};
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
// Initial setup // Initial setup
if (!this.hasUpdated) { if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace"); this.hass.loadFragmentTranslation("lovelace");
this._setLovelace(); this._loadConfig();
return; return;
} }
@@ -95,9 +97,28 @@ class PanelHome extends LitElement {
`; `;
} }
private async _loadConfig() {
try {
const data = await fetchFrontendSystemData(this.hass.connection, "home");
this._config = data || {};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load favorites:", err);
this._config = {};
}
this._setLovelace();
}
private async _setLovelace() { private async _setLovelace() {
const strategyConfig: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",
favorite_entities: this._config.favorite_entities,
},
};
const config = await generateLovelaceDashboardStrategy( const config = await generateLovelaceDashboardStrategy(
HOME_LOVELACE_CONFIG, strategyConfig,
this.hass this.hass
); );
@@ -121,15 +142,34 @@ class PanelHome extends LitElement {
} }
private _setEditMode = () => { private _setEditMode = () => {
// For now, we just show an alert that edit mode is not supported. showEditHomeDialog(this, {
// This will be expanded in the future. config: this._config,
showAlertDialog(this, { saveConfig: async (config) => {
title: "Edit mode not available", await this._saveConfig(config);
text: "The Home panel does not support edit mode.", },
confirmText: this.hass.localize("ui.common.ok"),
}); });
}; };
private async _saveConfig(config: HomeFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "home", config);
this._config = config || {};
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Failed to save home configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.home.editor.save_failed"),
duration: 0,
dismissable: true,
});
return;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
});
this._setLovelace();
}
static readonly styles: CSSResultGroup = css` static readonly styles: CSSResultGroup = css`
:host { :host {
display: block; display: block;

View File

@@ -1,5 +1,6 @@
import { ReactiveElement } from "lit"; import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import { import {
findEntities, findEntities,
generateEntityFilter, generateEntityFilter,
@@ -10,12 +11,7 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section"; import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface LightViewStrategyConfig { export interface LightViewStrategyConfig {
type: "light"; type: "light";
@@ -85,9 +81,9 @@ export class LightViewStrategy extends ReactiveElement {
_config: LightViewStrategyConfig, _config: LightViewStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
): Promise<LovelaceViewConfig> { ): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas); const areas = Object.values(hass.areas);
const floors = getFloors(hass.floors); const floors = Object.values(hass.floors);
const home = getHomeStructure(floors, areas); const hierarchy = getAreasFloorHierarchy(floors, areas);
const sections: LovelaceSectionRawConfig[] = []; const sections: LovelaceSectionRawConfig[] = [];
@@ -99,10 +95,11 @@ export class LightViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, lightFilters); const entities = findEntities(allEntities, lightFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0); const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
// Process floors // Process floors
for (const floorStructure of home.floors) { for (const floorStructure of hierarchy.floors) {
const floorId = floorStructure.id; const floorId = floorStructure.id;
const areaIds = floorStructure.areas; const areaIds = floorStructure.areas;
const floor = hass.floors[floorId]; const floor = hass.floors[floorId];
@@ -131,7 +128,7 @@ export class LightViewStrategy extends ReactiveElement {
} }
// Process unassigned areas // Process unassigned areas
if (home.areas.length > 0) { if (hierarchy.areas.length > 0) {
const section: LovelaceSectionRawConfig = { const section: LovelaceSectionRawConfig = {
type: "grid", type: "grid",
column_span: 2, column_span: 2,
@@ -146,7 +143,7 @@ export class LightViewStrategy extends ReactiveElement {
], ],
}; };
const areaCards = processAreasForLight(home.areas, hass, entities); const areaCards = processAreasForLight(hierarchy.areas, hass, entities);
if (areaCards.length > 0) { if (areaCards.length > 0) {
section.cards!.push(...areaCards); section.cards!.push(...areaCards);

View File

@@ -1,8 +1,9 @@
import { css, LitElement, nothing, html } from "lit"; import { css, LitElement, nothing, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../common/number/format_number";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types"; import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type { import type {
LovelaceCardFeatureContext, LovelaceCardFeatureContext,
BarGaugeCardFeatureConfig, BarGaugeCardFeatureConfig,
@@ -17,7 +18,7 @@ export const supportsBarGaugeCardFeature = (
: undefined; : undefined;
if (!stateObj) return false; if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id); const domain = computeDomain(stateObj.entity_id);
return domain === "sensor" && stateObj.attributes.unit_of_measurement === "%"; return domain === "sensor" && isNumericFromAttributes(stateObj.attributes);
}; };
@customElement("hui-bar-gauge-card-feature") @customElement("hui-bar-gauge-card-feature")
@@ -34,6 +35,11 @@ class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
}; };
} }
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-bar-gauge-card-feature-editor");
return document.createElement("hui-bar-gauge-card-feature-editor");
}
public setConfig(config: BarGaugeCardFeatureConfig): void { public setConfig(config: BarGaugeCardFeatureConfig): void {
if (!config) { if (!config) {
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
@@ -53,8 +59,20 @@ class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
return nothing; return nothing;
} }
const stateObj = this.hass.states[this.context.entity_id]; const stateObj = this.hass.states[this.context.entity_id];
const value = stateObj.state; const min = this._config.min ?? 0;
return html`<div style="width: ${value}%"></div> const max = this._config.max ?? 100;
const value = parseFloat(stateObj.state);
if (isNaN(value) || min >= max) {
return nothing;
}
const percentage = Math.max(
0,
Math.min(100, ((value - min) / (max - min)) * 100)
);
return html`<div style="width: ${percentage}%"></div>
<div class="bar-gauge-background"></div>`; <div class="bar-gauge-background"></div>`;
} }

View File

@@ -124,16 +124,24 @@ class HuiHistoryChartCardFeature
} }
const hourToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW; const hourToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const detail = this._config.detail !== false; // default to true (high detail)
return subscribeHistoryStatesTimeWindow( return subscribeHistoryStatesTimeWindow(
this.hass!, this.hass!,
(historyStates) => { (historyStates) => {
// sample to 1 point per hour for low detail or 1 point per 5 pixels for high detail
const maxDetails = detail
? Math.max(10, this.clientWidth / 5, hourToShow)
: Math.max(10, hourToShow);
const useMean = !detail;
const { points, yAxisOrigin } = const { points, yAxisOrigin } =
coordinatesMinimalResponseCompressedState( coordinatesMinimalResponseCompressedState(
historyStates[this.context!.entity_id!], historyStates[this.context!.entity_id!],
this.clientWidth, this.clientWidth,
this.clientHeight, this.clientHeight,
this.clientWidth / 5 // sample to 1 point per 5 pixels maxDetails,
undefined,
useMean
); );
this._coordinates = points; this._coordinates = points;
this._yAxisOrigin = yAxisOrigin; this._yAxisOrigin = yAxisOrigin;

View File

@@ -199,6 +199,7 @@ export interface UpdateActionsCardFeatureConfig {
export interface TrendGraphCardFeatureConfig { export interface TrendGraphCardFeatureConfig {
type: "trend-graph"; type: "trend-graph";
hours_to_show?: number; hours_to_show?: number;
detail?: boolean;
} }
export const AREA_CONTROLS = [ export const AREA_CONTROLS = [
@@ -226,6 +227,8 @@ export interface AreaControlsCardFeatureConfig {
export interface BarGaugeCardFeatureConfig { export interface BarGaugeCardFeatureConfig {
type: "bar-gauge"; type: "bar-gauge";
min?: number;
max?: number;
} }
export type LovelaceCardFeaturePosition = "bottom" | "inline"; export type LovelaceCardFeaturePosition = "bottom" | "inline";

View File

@@ -203,7 +203,7 @@ function formatTooltip(
countNegative++; countNegative++;
} }
} }
return `${param.marker} ${filterXSS(param.seriesName!)}: ${value} ${unit}`; return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
}) })
.filter(Boolean); .filter(Boolean);
let footer = ""; let footer = "";

View File

@@ -135,11 +135,13 @@ export class HuiEnergyDevicesGraphCard
return nothing; return nothing;
} }
const modes = this._getAllowedModes();
return html` return html`
<ha-card> <ha-card>
<div class="card-header"> <div class="card-header">
<span>${this._config.title ? this._config.title : nothing}</span> <span>${this._config.title ? this._config.title : nothing}</span>
${this._getAllowedModes().length > 1 ${modes.length > 1
? html` ? html`
<ha-icon-button <ha-icon-button
.path=${this._chartType === "pie" .path=${this._chartType === "pie"
@@ -166,7 +168,7 @@ export class HuiEnergyDevicesGraphCard
this._chartType, this._chartType,
this._legendData this._legendData
)} )}
.height=${`${Math.max(300, (this._legendData?.length || 0) * 28 + 50)}px`} .height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]} .extraComponents=${[PieChart]}
@chart-click=${this._handleChartClick} @chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden} @dataset-hidden=${this._datasetHidden}
@@ -185,7 +187,7 @@ export class HuiEnergyDevicesGraphCard
this.hass.locale, this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh`; )} kWh`;
return `${title}${params.marker} ${params.seriesName}: ${value}`; return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
} }
private _createOptions = memoizeOne( private _createOptions = memoizeOne(
@@ -492,7 +494,7 @@ export class HuiEnergyDevicesGraphCard
show: true, show: true,
position: "center", position: "center",
color: computedStyle.getPropertyValue("--secondary-text-color"), color: computedStyle.getPropertyValue("--secondary-text-color"),
fontSize: computedStyle.getPropertyValue("--ha-font-size-l"), fontSize: computedStyle.getPropertyValue("--ha-font-size-m"),
lineHeight: 24, lineHeight: 24,
fontWeight: "bold", fontWeight: "bold",
formatter: `{a}\n${formatNumber(totalChart, this.hass.locale)} kWh`, formatter: `{a}\n${formatNumber(totalChart, this.hass.locale)} kWh`,

View File

@@ -2,6 +2,7 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy"; import type { EnergyData } from "../../../../data/energy";
@@ -38,6 +39,8 @@ class HuiEnergySankeyCard
{ {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: EnergySankeyCardConfig; @state() private _config?: EnergySankeyCardConfig;
@state() private _data?: EnergyData; @state() private _data?: EnergyData;
@@ -385,7 +388,14 @@ class HuiEnergySankeyCard
(this._config.layout !== "horizontal" && this._isMobileSize); (this._config.layout !== "horizontal" && this._isMobileSize);
return html` return html`
<ha-card .header=${this._config.title}> <ha-card
.header=${this._config.title}
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
"is-vertical": vertical,
})}
>
<div class="card-content"> <div class="card-content">
${hasData ${hasData
? html`<ha-sankey-chart ? html`<ha-sankey-chart
@@ -402,7 +412,9 @@ class HuiEnergySankeyCard
} }
private _valueFormatter = (value: number) => private _valueFormatter = (value: number) =>
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`; `<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
protected _groupByFloorAndArea(deviceNodes: Node[]) { protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = { const areas: Record<string, { value: number; devices: Node[] }> = {
@@ -508,17 +520,18 @@ class HuiEnergySankeyCard
} }
static styles = css` static styles = css`
:host {
display: block;
height: calc(
var(--row-size, 8) *
(var(--row-height, 50px) + var(--row-gap, 0px)) - var(--row-gap, 0px)
);
}
ha-card { ha-card {
height: 100%; height: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
--chart-max-height: none;
}
ha-card.is-vertical {
height: 500px;
}
ha-card.is-grid,
ha-card.is-panel {
height: 100%;
} }
.card-content { .card-content {
flex: 1; flex: 1;

View File

@@ -0,0 +1,739 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { PowerSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
interface PowerData {
solar: number;
from_grid: number;
to_grid: number;
from_battery: number;
to_battery: number;
grid_to_battery: number;
battery_to_grid: number;
solar_to_battery: number;
solar_to_grid: number;
used_solar: number;
used_grid: number;
used_battery: number;
used_total: number;
}
@customElement("hui-power-sankey-card")
class HuiPowerSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: PowerSankeyCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: PowerSankeyCardConfig): void {
this._config = { ...DEFAULT_CONFIG, ...config };
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 5;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 6,
min_rows: 2,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (
changedProps.has("_config") ||
changedProps.has("_data") ||
changedProps.has("_isMobileSize")
) {
return true;
}
// Check if any of the tracked entity states have changed
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
// Only update if one of our tracked entities changed
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
protected render() {
if (!this._config) {
return nothing;
}
if (!this._data) {
return html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.loading"
)}`;
}
const prefs = this._data.prefs;
const powerData = this._computePowerData(prefs);
const computedStyle = getComputedStyle(this);
const nodes: Node[] = [];
const links: Link[] = [];
// Create home node
const homeNode: Node = {
id: "home",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.home"
),
value: Math.max(0, powerData.used_total),
color: computedStyle.getPropertyValue("--primary-color").trim(),
index: 1,
};
nodes.push(homeNode);
// Add battery source and sink if available
if (powerData.from_battery > 0) {
nodes.push({
id: "battery",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: powerData.from_battery,
color: computedStyle
.getPropertyValue("--energy-battery-out-color")
.trim(),
index: 0,
});
links.push({
source: "battery",
target: "home",
});
}
if (powerData.to_battery > 0) {
nodes.push({
id: "battery_in",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: powerData.to_battery,
color: computedStyle
.getPropertyValue("--energy-battery-in-color")
.trim(),
index: 1,
});
if (powerData.grid_to_battery > 0) {
links.push({
source: "grid",
target: "battery_in",
});
}
if (powerData.solar_to_battery > 0) {
links.push({
source: "solar",
target: "battery_in",
});
}
}
// Add grid source if available
if (powerData.from_grid > 0) {
nodes.push({
id: "grid",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
),
value: powerData.from_grid,
color: computedStyle
.getPropertyValue("--energy-grid-consumption-color")
.trim(),
index: 0,
});
links.push({
source: "grid",
target: "home",
});
}
// Add solar if available
if (powerData.solar > 0) {
nodes.push({
id: "solar",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.solar"
),
value: powerData.solar,
color: computedStyle.getPropertyValue("--energy-solar-color").trim(),
index: 0,
});
links.push({
source: "solar",
target: "home",
});
}
// Add grid return if available
if (powerData.to_grid > 0) {
nodes.push({
id: "grid_return",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
),
value: powerData.to_grid,
color: computedStyle
.getPropertyValue("--energy-grid-return-color")
.trim(),
index: 2,
});
if (powerData.battery_to_grid > 0) {
links.push({
source: "battery",
target: "grid_return",
});
}
if (powerData.solar_to_grid > 0) {
links.push({
source: "solar",
target: "grid_return",
});
}
}
let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
prefs.device_consumption.forEach((device, idx) => {
if (!device.stat_rate) {
return;
}
const value = this._getCurrentPower(device.stat_rate);
if (value < 0.01) {
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const { group_by_area, group_by_floor } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
// link "no_floor" areas to home
floorNodeId = "home";
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: "home",
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
// If group_by_area is false, link devices to floor or home
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or home)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: "home",
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
});
// untracked consumption
if (untrackedConsumption > 0) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: "home",
target: "untracked",
value: untrackedConsumption,
});
}
const hasData = nodes.some((node) => node.value > 0);
const vertical =
this._config.layout === "vertical" ||
(this._config.layout !== "horizontal" && this._isMobileSize);
return html`
<ha-card
.header=${this._config.title}
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
"is-vertical": vertical,
})}
>
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data"
)}`}
</div>
</ha-card>
`;
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kW</div>`;
/**
* Compute real-time power data from current entity states.
* Similar to computeConsumptionData but for instantaneous power.
*/
private _computePowerData(prefs: EnergyPreferences): PowerData {
// Clear tracked entities and rebuild the set
this._entities.clear();
let solar = 0;
let from_grid = 0;
let to_grid = 0;
let from_battery = 0;
let to_battery = 0;
// Collect solar power
prefs.energy_sources
.filter((source) => source.type === "solar")
.forEach((source) => {
if (source.type === "solar" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) {
solar += value;
}
}
});
// Collect grid power (positive = import, negative = export)
prefs.energy_sources
.filter((source) => source.type === "grid" && source.power)
.forEach((source) => {
if (source.type === "grid" && source.power) {
source.power.forEach((powerSource) => {
const value = this._getCurrentPower(powerSource.stat_rate);
if (value > 0) {
from_grid += value;
} else if (value < 0) {
to_grid += Math.abs(value);
}
});
}
});
// Collect battery power (positive = discharge, negative = charge)
prefs.energy_sources
.filter((source) => source.type === "battery")
.forEach((source) => {
if (source.type === "battery" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) {
from_battery += value;
} else if (value < 0) {
to_battery += Math.abs(value);
}
}
});
// Calculate total consumption
const used_total = from_grid + solar + from_battery - to_grid - to_battery;
// Determine power routing using priority logic
// Priority: Solar -> Battery_In, Solar -> Grid_Out, Battery_Out -> Grid_Out,
// Grid_In -> Battery_In, Solar -> Consumption, Battery_Out -> Consumption, Grid_In -> Consumption
let solar_remaining = solar;
let grid_remaining = from_grid;
let battery_remaining = from_battery;
let to_battery_remaining = to_battery;
let to_grid_remaining = to_grid;
let used_total_remaining = Math.max(used_total, 0);
let grid_to_battery = 0;
let battery_to_grid = 0;
let solar_to_battery = 0;
let solar_to_grid = 0;
let used_solar = 0;
let used_battery = 0;
let used_grid = 0;
// Handle excess grid input to battery first
const excess_grid_in_after_consumption = Math.max(
0,
Math.min(to_battery_remaining, grid_remaining - used_total_remaining)
);
grid_to_battery += excess_grid_in_after_consumption;
to_battery_remaining -= excess_grid_in_after_consumption;
grid_remaining -= excess_grid_in_after_consumption;
// Solar -> Battery_In
solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
to_battery_remaining -= solar_to_battery;
solar_remaining -= solar_to_battery;
// Solar -> Grid_Out
solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
to_grid_remaining -= solar_to_grid;
solar_remaining -= solar_to_grid;
// Battery_Out -> Grid_Out
battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
battery_remaining -= battery_to_grid;
to_grid_remaining -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
grid_to_battery += grid_to_battery_2;
grid_remaining -= grid_to_battery_2;
to_battery_remaining -= grid_to_battery_2;
// Solar -> Consumption
used_solar = Math.min(used_total_remaining, solar_remaining);
used_total_remaining -= used_solar;
solar_remaining -= used_solar;
// Battery_Out -> Consumption
used_battery = Math.min(battery_remaining, used_total_remaining);
battery_remaining -= used_battery;
used_total_remaining -= used_battery;
// Grid_In -> Consumption
used_grid = Math.min(used_total_remaining, grid_remaining);
grid_remaining -= used_grid;
used_total_remaining -= used_grid;
return {
solar,
from_grid,
to_grid,
from_battery,
to_battery,
grid_to_battery,
battery_to_grid,
solar_to_battery,
solar_to_grid,
used_solar,
used_grid,
used_battery,
used_total: Math.max(0, used_total),
};
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {
value: 0,
devices: [],
},
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: {
value: 0,
areas: ["no_area"],
},
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
// see if the area has a floor
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
}
/**
* Get current power value from entity state, normalized to kW
* @param entityId - The entity ID to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
*/
private _getCurrentPower(entityId: string): number {
// Track this entity for state change detection
this._entities.add(entityId);
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return 0;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return 0;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value * 1000;
case "GW":
return value * 1000000;
case "TW":
return value * 1000000000;
default:
// Assume kW if no unit or unit is kW
return value;
}
}
/**
* Get entity label (friendly name or entity ID)
* @param entityId - The entity ID to get label for
* @returns Friendly name if available, otherwise the entity ID
*/
private _getEntityLabel(entityId: string): string {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return entityId;
}
return stateObj.attributes.friendly_name || entityId;
}
static styles = css`
ha-card {
height: 400px;
display: flex;
flex-direction: column;
--chart-max-height: none;
}
ha-card.is-vertical {
height: 500px;
}
ha-card.is-grid,
ha-card.is-panel {
height: 100%;
}
.card-content {
flex: 1;
display: flex;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-sankey-card": HuiPowerSankeyCard;
}
}

View File

@@ -21,6 +21,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options"; import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts"; import type { ECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color"; import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@customElement("hui-power-sources-graph-card") @customElement("hui-power-sources-graph-card")
export class HuiPowerSourcesGraphCard export class HuiPowerSourcesGraphCard
@@ -33,6 +34,8 @@ export class HuiPowerSourcesGraphCard
@state() private _chartData: LineSeriesOption[] = []; @state() private _chartData: LineSeriesOption[] = [];
@state() private _legendData?: CustomLegendOption["data"];
@state() private _start = startOfToday(); @state() private _start = startOfToday();
@state() private _end = endOfToday(); @state() private _end = endOfToday();
@@ -91,7 +94,8 @@ export class HuiPowerSourcesGraphCard
this.hass.locale, this.hass.locale,
this.hass.config, this.hass.config,
this._compareStart, this._compareStart,
this._compareEnd this._compareEnd,
this._legendData
)} )}
></ha-chart-base> ></ha-chart-base>
${!this._chartData.some((dataset) => dataset.data!.length) ${!this._chartData.some((dataset) => dataset.data!.length)
@@ -115,9 +119,10 @@ export class HuiPowerSourcesGraphCard
locale: FrontendLocaleData, locale: FrontendLocaleData,
config: HassConfig, config: HassConfig,
compareStart?: Date, compareStart?: Date,
compareEnd?: Date compareEnd?: Date,
): ECOption => legendData?: CustomLegendOption["data"]
getCommonOptions( ): ECOption => ({
...getCommonOptions(
start, start,
end, end,
locale, locale,
@@ -125,11 +130,18 @@ export class HuiPowerSourcesGraphCard
"kW", "kW",
compareStart, compareStart,
compareEnd compareEnd
) ),
legend: {
show: true,
type: "custom",
data: legendData,
},
})
); );
private async _getStatistics(energyData: EnergyData): Promise<void> { private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: LineSeriesOption[] = []; const datasets: LineSeriesOption[] = [];
this._legendData = [];
const statIds = { const statIds = {
solar: { solar: {
@@ -238,6 +250,15 @@ export class HuiPowerSourcesGraphCard
z: 4 - keyIndex, // draw in reverse order but above positive series z: 4 - keyIndex, // draw in reverse order but above positive series
}); });
} }
this._legendData!.push({
id: key,
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
name: statIds[key].name,
itemStyle: {
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
borderColor: colorHex,
},
});
} }
}); });
@@ -268,11 +289,23 @@ export class HuiPowerSourcesGraphCard
name: this.hass.localize( name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.usage" "ui.panel.lovelace.cards.energy.power_graph.usage"
), ),
color: computedStyles.getPropertyValue("--primary-color"), color: computedStyles.getPropertyValue("--primary-text-color"),
lineStyle: { width: 2 }, lineStyle: {
type: [7, 2],
width: 1.5,
},
data: usageData, data: usageData,
z: 5, z: 5,
}); });
this._legendData!.push({
id: "usage",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.usage"
),
itemStyle: {
color: computedStyles.getPropertyValue("--primary-text-color"),
},
});
} }
private _processData(stats: StatisticValue[][]) { private _processData(stats: StatisticValue[][]) {

View File

@@ -150,11 +150,6 @@ export interface EnergyCardBaseConfig extends LovelaceCardConfig {
collection_key?: string; collection_key?: string;
} }
export interface EnergySummaryCardConfig extends EnergyCardBaseConfig {
type: "energy-summary";
title?: string;
}
export interface EnergyDistributionCardConfig extends EnergyCardBaseConfig { export interface EnergyDistributionCardConfig extends EnergyCardBaseConfig {
type: "energy-distribution"; type: "energy-distribution";
title?: string; title?: string;
@@ -236,6 +231,14 @@ export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
title?: string; title?: string;
} }
export interface PowerSankeyCardConfig extends EnergyCardBaseConfig {
type: "power-sankey";
title?: string;
layout?: "vertical" | "horizontal" | "auto";
group_by_floor?: boolean;
group_by_area?: boolean;
}
export interface EntityFilterCardConfig extends LovelaceCardConfig { export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter"; type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[]; entities: (EntityFilterEntityConfig | string)[];

View File

@@ -68,6 +68,7 @@ const LAZY_LOAD_TYPES = {
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"), "energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"power-sources-graph": () => "power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"), import("../cards/energy/hui-power-sources-graph-card"),
"power-sankey": () => import("../cards/energy/hui-power-sankey-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"), "entity-filter": () => import("../cards/hui-entity-filter-card"),
error: () => import("../cards/hui-error-card"), error: () => import("../cards/hui-error-card"),
"home-summary": () => import("../cards/hui-home-summary-card"), "home-summary": () => import("../cards/hui-home-summary-card"),

View File

@@ -21,7 +21,10 @@ import {
} from "../../../../data/lovelace_custom_cards"; } from "../../../../data/lovelace_custom_cards";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles"; import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge"; import "../../badges/hui-badge";
@@ -395,6 +398,7 @@ export class HuiDialogEditBadge
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
:host { :host {
--code-mirror-max-height: calc(100vh - 176px); --code-mirror-max-height: calc(100vh - 176px);
@@ -403,8 +407,6 @@ export class HuiDialogEditBadge
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 100px; --mdc-dialog-max-width: 100px;
--dialog-z-index: 6; --dialog-z-index: 6;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-width: 90vw; --mdc-dialog-max-width: 90vw;
--dialog-content-padding: 24px 12px; --dialog-content-padding: 24px 12px;
} }

View File

@@ -184,19 +184,15 @@ export class HuiCreateDialogCard
return [ return [
haStyleDialog, haStyleDialog,
css` css`
@media all and (max-width: 450px), all and (max-height: 500px) {
/* overrule the ha-style-dialog max-height on small screens */
ha-dialog {
--mdc-dialog-max-height: 100%;
height: 100%;
}
}
@media all and (min-width: 850px) { @media all and (min-width: 850px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: 845px; --mdc-dialog-min-width: 845px;
--mdc-dialog-min-height: calc(100vh - 72px); --mdc-dialog-min-height: calc(
--mdc-dialog-max-height: calc(100vh - 72px); 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
--mdc-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
} }
} }

View File

@@ -21,7 +21,10 @@ import {
} from "../../../../data/lovelace_custom_cards"; } from "../../../../data/lovelace_custom_cards";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles"; import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast"; import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
@@ -371,6 +374,7 @@ export class HuiDialogEditCard
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
:host { :host {
--code-mirror-max-height: calc(100vh - 176px); --code-mirror-max-height: calc(100vh - 176px);
@@ -379,8 +383,6 @@ export class HuiDialogEditCard
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 100px; --mdc-dialog-max-width: 100px;
--dialog-z-index: 6; --dialog-z-index: 6;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-width: 90vw; --mdc-dialog-max-width: 90vw;
--dialog-content-padding: 24px 12px; --dialog-content-padding: 24px 12px;
} }

View File

@@ -0,0 +1,87 @@
import { 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-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type {
BarGaugeCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-bar-gauge-card-feature-editor")
export class HuiBarGaugeCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: BarGaugeCardFeatureConfig;
public setConfig(config: BarGaugeCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
() =>
[
{
name: "min",
default: 0,
selector: {
number: {
mode: "box",
},
},
},
{
name: "max",
default: 100,
selector: {
number: {
mode: "box",
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema();
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.bar-gauge.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-bar-gauge-card-feature-editor": HuiBarGaugeCardFeatureEditor;
}
}

View File

@@ -123,6 +123,7 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"alarm-modes", "alarm-modes",
"area-controls", "area-controls",
"bar-gauge",
"button", "button",
"climate-fan-modes", "climate-fan-modes",
"climate-hvac-modes", "climate-hvac-modes",

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