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.
**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
- [Quick Reference](#quick-reference)
@@ -151,6 +153,10 @@ try {
### Styling Guidelines
- **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
- **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages
@@ -159,21 +165,68 @@ try {
static get styles() {
return css`
:host {
--spacing: 16px;
padding: var(--spacing);
padding: var(--ha-space-4);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) {
: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
- **Code split**: Split code at the panel/dialog level
@@ -195,8 +248,9 @@ static get styles() {
**Available Dialog Types:**
- `ha-md-dialog` - Preferred for new code (Material Design 3)
- `ha-dialog` - Legacy component still widely used
- `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
- `ha-md-dialog` - Material Design 3 dialog component
- `ha-dialog` - Legacy component (still widely used)
**Opening Dialogs (Fire Event Pattern - Recommended):**
@@ -211,15 +265,45 @@ fireEvent(this, "show-dialog", {
**Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface
- Use `createCloseHeading()` for standard headers
- Import `haStyleDialog` for consistent styling
- Use `@state() private _open = false` to control dialog visibility
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
- Return `nothing` when no params (loading state)
- Fire `dialog-closed` event when closing
- Add `dialogInitialFocus` for accessibility
- Fire `dialog-closed` event in `_dialogClosed()` handler
- 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)
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
@@ -235,7 +319,11 @@ fireEvent(this, "show-dialog", {
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
````
```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert)
@@ -249,6 +337,35 @@ fireEvent(this, "show-dialog", {
<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
### Creating a Panel
@@ -289,11 +406,19 @@ export class DialogMyFeature
@state()
private _params?: MyDialogParams;
@state()
private _open = false;
public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -304,23 +429,27 @@ export class DialogMyFeature
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(this.hass, this._params.title)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title}
header-subtitle=${this._params.subtitle}
@closed=${this._dialogClosed}
>
<!-- Dialog content -->
<ha-button
appearance="plain"
@click=${this.closeDialog}
slot="secondaryAction"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
<p>Dialog content</p>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
# 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)
- 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.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- 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
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)
```html
<ha-dropdown open>
<ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>

View File

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

View File

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

View File

@@ -59,7 +59,8 @@ export class HaAuthFlow extends LitElement {
willUpdate(changedProps: PropertyValues) {
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;
}

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 },
dataZoom: this._getDataZoomConfig(),
toolbox: {
top: Infinity,
left: Infinity,
top: Number.MAX_SAFE_INTEGER,
left: Number.MAX_SAFE_INTEGER,
feature: {
dataZoom: { show: true, yAxisIndex: false, filterMode: "none" },
dataZoom: {
show: true,
yAxisIndex: false,
filterMode: "none",
showTitle: false,
},
},
iconStyle: { opacity: 0 },
},

View File

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

View File

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

View File

@@ -298,6 +298,18 @@ export class HaDataTable extends LitElement {
}
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(
(row) => row.selectable !== false
).length;

View File

@@ -17,6 +17,7 @@ import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -40,7 +41,11 @@ export class HaAssistChat extends LitElement {
@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[] = [];
@@ -92,10 +97,7 @@ export class HaAssistChat extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._audioRecorder?.close();
this._audioRecorder = undefined;
this._unloadAudio();
this._conversation = [];
this._conversationId = null;
}
protected render(): TemplateResult {
@@ -112,7 +114,7 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html`
<div class="messages" id="scroll-container">
<div class="messages">
${controlHA
? nothing
: html`
@@ -124,11 +126,18 @@ export class HaAssistChat extends LitElement {
`}
<div class="spacer"></div>
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(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 class="input" slot="primaryAction">
@@ -189,12 +198,28 @@ export class HaAssistChat extends LitElement {
`;
}
private _scrollMessagesBottom() {
const scrollContainer = this._scrollContainer;
if (!scrollContainer) {
return;
private async _scrollMessagesBottom() {
const lastChatMessage = this._lastChatMessage;
if (!lastChatMessage.hasUpdated) {
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) {
@@ -586,42 +611,31 @@ export class HaAssistChat extends LitElement {
flex: 1;
}
.message {
white-space: pre-line;
font-size: var(--ha-font-size-l);
clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: 24px;
margin: 8px 0;
padding: 8px;
border-radius: var(--ha-border-radius-xl);
}
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
font-size: var(--ha-font-size-l);
}
}
.message p {
margin: 0;
}
.message p:not(:last-child) {
margin-bottom: 8px;
}
.message.user {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color);
direction: var(--direction);
}
.message.hass {
margin-right: 24px;
margin-inline-end: 24px;
@@ -636,20 +650,21 @@ export class HaAssistChat extends LitElement {
color: var(--primary-text-color);
direction: var(--direction);
}
.message.user a {
color: var(--text-primary-color);
}
.message.hass a {
color: var(--primary-text-color);
}
.message.error {
background-color: var(--error-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 {
width: 48px;
height: 48px;

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { ReactiveElement, render, html } from "lit";
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 { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager";
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = {
@@ -48,18 +52,26 @@ class HaMarkdownElement extends ReactiveElement {
return this;
}
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
protected update(changedProps) {
super.update(changedProps);
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 {
if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey();
if (markdownCache.has(key)) {
this.innerHTML = markdownCache.get(key)!;
render(markdownCache.get(key)!, this.renderRoot);
this._resize();
}
}
@@ -75,7 +87,7 @@ class HaMarkdownElement extends ReactiveElement {
}
private async _render() {
this.innerHTML = await renderMarkdown(
const elements = await renderMarkdown(
String(this.content),
{
breaks: this.breaks,
@@ -87,6 +99,11 @@ class HaMarkdownElement extends ReactiveElement {
}
);
render(
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
this._resize();
const walker = document.createTreeWalker(

View File

@@ -1,5 +1,12 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import {
css,
html,
LitElement,
nothing,
type ReactiveElement,
type CSSResultGroup,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import "./ha-markdown-element";
@customElement("ha-markdown")
@@ -18,6 +25,14 @@ export class HaMarkdown extends LitElement {
@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() {
if (!this.content) {
return nothing;
@@ -53,19 +68,46 @@ export class HaMarkdown extends LitElement {
margin: var(--ha-space-1) 0;
}
a {
color: var(--primary-color);
color: var(--markdown-link-color, var(--primary-color));
}
img {
background-color: rgba(10, 10, 10, 0.15);
border-radius: var(--markdown-image-border-radius);
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,
pre {
background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm);
}
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
color: var(--markdown-code-text-color, inherit);
}
code {
font-size: var(--ha-font-size-s);
@@ -97,6 +139,24 @@ export class HaMarkdown extends LitElement {
border-bottom: none;
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;
}

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
}
.container {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
margin-top: var(--safe-area-inset-top, var(--ha-space-0));
margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
margin-right: var(--safe-area-inset-right, var(--ha-space-0));
}
}
@@ -187,7 +187,7 @@ export class HaMdDialog extends Dialog {
}
slot[name="actions"]::slotted(*) {
padding: 16px;
padding: var(--ha-space-4);
}
.scroller {
@@ -195,7 +195,7 @@ export class HaMdDialog extends Dialog {
}
slot[name="content"]::slotted(*) {
padding: var(--dialog-content-padding, 24px);
padding: var(--dialog-content-padding, var(--ha-space-6));
}
.scrim {
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 { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
@@ -44,7 +45,7 @@ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard",
title:
panel.url_path === hass.defaultPanel
panel.url_path === getDefaultPanelUrlPath(hass)
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ export interface AnalyticsPreferences {
diagnostics?: boolean;
usage?: boolean;
statistics?: boolean;
snapshots?: boolean;
}
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 { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -12,11 +13,7 @@ import {
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import {
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
import type { FloorRegistryEntry } from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
@@ -182,68 +179,59 @@ export const getAreasAndFloors = (
);
}
const floorAreaLookup = getFloorAreaLookup(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 hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
const floorName = computeFloorName(floor);
hierarchy.floors.forEach((f) => {
const floor = haFloors[f.id];
const floorAreas = f.areas.map((areaId) => haAreas[areaId]);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area);
return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases];
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
const areaName = computeAreaName(area);
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
};
})
);
});
items.push(
...unassignedAreas.map((area) => {
...hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ id: area.area_id, type: "area" }),

View File

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

View File

@@ -10,6 +10,7 @@ import {
import { formatTime } from "../common/datetime/format_time";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { fileDownload } from "../util/file_download";
import { handleFetchPromise } from "../util/hass-call-api";
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")}
${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 = (
hass: HomeAssistant,

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
}
export interface SidebarFrontendUserData {
@@ -10,15 +11,29 @@ export interface SidebarFrontendUserData {
hiddenPanels: string[];
}
export interface CoreFrontendSystemData {
default_panel?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
}
declare global {
interface FrontendUserData {
core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData;
}
interface FrontendSystemData {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
}
}
export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey,
>(
@@ -59,3 +74,46 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
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";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace";
export const getStorageDefaultPanelUrlPath = (): string => {
export const getLegacyDefaultPanelUrlPath = (): string | null => {
const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : DEFAULT_PANEL;
return defaultPanel ? JSON.parse(defaultPanel) : null;
};
export const setDefaultPanel = (
element: HTMLElement,
urlPath: string
): void => {
fireEvent(element, "hass-default-panel", { defaultPanel: urlPath });
};
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
hass.userData?.default_panel ||
hass.systemData?.default_panel ||
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo =>
hass.panels[hass.defaultPanel]
? hass.panels[hass.defaultPanel]
: hass.panels[DEFAULT_PANEL];
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") {

View File

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

View File

@@ -50,7 +50,7 @@ import { lightSupportsFavoriteColors } from "../../data/light";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyleDialog } from "../../resources/styles";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types";
import {
@@ -707,14 +707,9 @@ export class MoreInfoDialog extends LitElement {
static get styles() {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
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;
}
@@ -737,13 +732,6 @@ export class MoreInfoDialog extends LitElement {
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) {
ha-dialog {
--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 { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
@@ -986,6 +990,7 @@ export class QuickBar extends LitElement {
return [
haStyleScrollbar,
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-list {
position: relative;
@@ -1010,9 +1015,9 @@ export class QuickBar extends LitElement {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px;
--dialog-surface-position: fixed;
--dialog-surface-top: var(--ha-space-10);
--mdc-dialog-max-height: calc(100% - var(--ha-space-18));
--mdc-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import { documentationUrl } from "../util/documentation-url";
import type { HomeAssistant } from "../types";
import { showAppDialog } from "./dialogs/show-app-dialog";
import { showCommunityDialog } from "./dialogs/show-community-dialog";
@@ -22,7 +23,10 @@ class OnboardingWelcomeLinks extends LitElement {
return html`<a
target="_blank"
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
noninteractive

View File

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

View File

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

View File

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

View File

@@ -2,38 +2,47 @@ import type { ActionDetail } from "@material/mwc-list";
import {
mdiDelete,
mdiDotsVertical,
mdiDragHorizontalVariant,
mdiHelpCircle,
mdiPencil,
mdiPlus,
} from "@mdi/js";
import {
LitElement,
type PropertyValues,
type TemplateResult,
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
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 "../../../components/ha-fab";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
createAreaRegistryEntry,
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import {
createFloorRegistryEntry,
deleteFloorRegistryEntry,
getFloorAreaLookup,
reorderFloorRegistryEntries,
updateFloorRegistryEntry,
} from "../../../data/floor_registry";
import {
@@ -42,6 +51,7 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import {
@@ -52,7 +62,17 @@ import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-deta
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")
export class HaConfigAreasDashboard extends LitElement {
@@ -64,55 +84,50 @@ export class HaConfigAreasDashboard extends LitElement {
@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"],
entities: HomeAssistant["entities"],
floors: HomeAssistant["floors"]
) => {
const processArea = (area: AreaRegistryEntry) => {
let noDevicesInArea = 0;
let noServicesInArea = 0;
let noEntitiesInArea = 0;
entities: HomeAssistant["entities"]
): Map<string, AreaStats> => {
const computeAreaStats = (area: AreaRegistryEntry) => {
let devicesCount = 0;
let servicesCount = 0;
let entitiesCount = 0;
for (const device of Object.values(devices)) {
if (device.area_id === area.area_id) {
if (device.entry_type === "service") {
noServicesInArea++;
servicesCount++;
} else {
noDevicesInArea++;
devicesCount++;
}
}
}
for (const entity of Object.values(entities)) {
if (entity.area_id === area.area_id) {
noEntitiesInArea++;
entitiesCount++;
}
}
return {
...area,
devices: noDevicesInArea,
services: noServicesInArea,
entities: noEntitiesInArea,
devices: devicesCount,
services: servicesCount,
entities: entitiesCount,
};
};
const floorAreaLookup = getFloorAreaLookup(areas);
const unassignedAreas = areas.filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
return {
floors: Object.values(floors).map((floor) => ({
...floor,
areas: (floorAreaLookup[floor.floor_id] || []).map(processArea),
})),
unassignedAreas: unassignedAreas.map(processArea),
};
const areaStats = new Map<string, AreaStats>();
Object.values(areas).forEach((area) => {
areaStats.set(area.area_id, computeAreaStats(area));
});
return areaStats;
}
);
@@ -120,25 +135,32 @@ export class HaConfigAreasDashboard extends LitElement {
super.willUpdate(changedProperties);
if (changedProperties.has("hass")) {
const oldHass = changedProperties.get("hass");
if (this.hass.areas !== oldHass?.areas) {
this._areas = Object.values(this.hass.areas);
if (
(this.hass.areas !== oldHass?.areas ||
this.hass.floors !== oldHass?.floors) &&
!this._blockHierarchyUpdate
) {
this._computeHierarchy();
}
}
}
protected render(): TemplateResult {
const areasAndFloors =
!this.hass.areas ||
!this.hass.devices ||
!this.hass.entities ||
!this.hass.floors
? undefined
: this._processAreas(
this._areas,
this.hass.devices,
this.hass.entities,
this.hass.floors
);
private _computeHierarchy() {
this._hierarchy = getAreasFloorHierarchy(
Object.values(this.hass.floors),
Object.values(this.hass.areas)
);
}
protected render(): TemplateResult<1> | typeof nothing {
if (!this._hierarchy) {
return nothing;
}
const areasStats = this._processAreasStats(
this.hass.areas,
this.hass.devices,
this.hass.entities
);
return html`
<hass-tabs-subpage
@@ -157,81 +179,120 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp}
></ha-icon-button>
<div class="container">
${areasAndFloors?.floors.map(
(floor) =>
html`<div class="floor">
<div class="header">
<h2>
<ha-floor-icon .floor=${floor}></ha-floor-icon>
${floor.name}
</h2>
<ha-button-menu
.floor=${floor}
@action=${this._handleFloorAction}
<ha-sortable
handle-selector=".handle"
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">
<h2>
<ha-floor-icon .floor=${floor}></ha-floor-icon>
${floor.name}
</h2>
<div class="actions">
<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-button-menu
.floor=${floor}
@action=${this._handleFloorAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.edit_floor"
)}</ha-list-item
>
<ha-list-item class="warning" graphic="icon"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.delete_floor"
)}</ha-list-item
>
</ha-button-menu>
</div>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${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>
</ha-sortable>
</div>
`;
})}
</div>
</ha-sortable>
${this._hierarchy.areas.length
? html`
<div class="floor">
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
)}
</h2>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${UNASSIGNED_FLOOR}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.edit_floor"
)}</ha-list-item
>
<ha-list-item class="warning" graphic="icon"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.delete_floor"
)}</ha-list-item
>
</ha-button-menu>
<div class="areas">
${this._hierarchy.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>
</ha-sortable>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${floor.areas.map((area) => this._renderArea(area))}
</div>
</ha-sortable>
</div>`
)}
${areasAndFloors?.unassignedAreas.length
? html`<div class="floor">
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
)}
</h2>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.floor=${UNASSIGNED_FLOOR}
>
<div class="areas">
${areasAndFloors?.unassignedAreas.map((area) =>
this._renderArea(area)
)}
</div>
</ha-sortable>
</div>`
`
: nothing}
</div>
<ha-fab
@@ -259,56 +320,60 @@ export class HaConfigAreasDashboard extends LitElement {
`;
}
private _renderArea(area) {
return html`<a
href=${`/config/areas/area/${area.area_id}`}
.sortableData=${area}
>
<ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture ? `url(${area.picture})` : undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
>
${!area.picture && area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: ""}
</div>
<div class="card-header">
${area.name}
<ha-icon-button
.area=${area}
.path=${mdiPencil}
@click=${this._openAreaDetails}
></ha-icon-button>
</div>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
area.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: area.devices }
),
area.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: area.services }
),
area.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: area.entities }
),
].filter((v): v is string => Boolean(v))
)}
private _renderArea(
area: AreaRegistryEntry,
stats: AreaStats | undefined
): TemplateResult<1> {
return html`
<a href=${`/config/areas/area/${area.area_id}`} .sortableData=${area}>
<ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture
? `url(${area.picture})`
: undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
>
${!area.picture && area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: ""}
</div>
</div>
</ha-card>
</a>`;
<div class="card-header">
${area.name}
<ha-icon-button
.area=${area}
.path=${mdiPencil}
@click=${this._openAreaDetails}
></ha-icon-button>
</div>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
stats?.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: stats.devices }
),
stats?.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: stats.services }
),
stats?.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: stats.entities }
),
].filter((v): v is string => Boolean(v))
)}
</div>
</div>
</ha-card>
</a>
`;
}
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) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { floor } = ev.currentTarget;
const { data: area, index } = ev.detail;
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) => {
if (a.area_id === area.area_id) {
return { ...a, floor_id: newFloorId };
}
return a;
});
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === newFloorId) {
return {
...f,
areas: insertAtIndex(f.areas, area.area_id, index),
};
}
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),
};
await updateAreaRegistryEntry(this.hass, area.area_id, {
floor_id: newFloorId,
});
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, {
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>) {
@@ -463,6 +674,10 @@ export class HaConfigAreasDashboard extends LitElement {
.header ha-icon {
margin-inline-end: 8px;
}
.header .actions {
display: flex;
align-items: center;
}
.areas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@@ -473,6 +688,10 @@ export class HaConfigAreasDashboard extends LitElement {
.areas > * {
max-width: 500px;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}
ha-card {
overflow: hidden;
}

View File

@@ -1336,7 +1336,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-1);
--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);
}
ha-bottom-sheet .groups {
@@ -1400,7 +1400,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-2);
--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);
padding: var(--ha-space-0) var(--ha-space-4);
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,14 @@
import { mdiOpenInNew } from "@mdi/js";
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 { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-analytics";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-checkbox";
import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import type { Analytics } from "../../../data/analytics";
import {
getAnalyticsDetails,
@@ -13,8 +17,6 @@ import {
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { isDevVersion } from "../../../common/config/version";
import type { HaSwitch } from "../../../components/ha-switch";
@customElement("ha-config-analytics")
class ConfigAnalytics extends LitElement {
@@ -32,22 +34,10 @@ class ConfigAnalytics extends LitElement {
: undefined;
return html`
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.analytics.header") ||
"Home Assistant analytics"}
>
<ha-card outlined>
<div class="card-content">
${error ? html`<div class="error">${error}</div>` : nothing}
<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>
${error ? html`<div class="error">${error}</div>` : ""}
<p>${this.hass.localize("ui.panel.config.analytics.intro")}</p>
<ha-analytics
translation_key_panel="config"
@analytics-preferences-changed=${this._preferencesChanged}
@@ -55,50 +45,26 @@ class ConfigAnalytics extends LitElement {
.analytics=${this._analyticsDetails}
></ha-analytics>
</div>
</ha-card>
${isDevVersion(this.hass.config.version)
? html`<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.header"
<div class="card-actions">
<ha-button @click=${this._save}>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.save_button"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.info"
)}
<a
href=${documentationUrl(this.hass, "/device-database/")}
target="_blank"
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-settings-row>
</div>
</ha-card>`
: nothing}
</ha-button>
</div>
</ha-card>
<div class="footer">
<ha-button
size="small"
appearance="plain"
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
${this.hass.localize("ui.panel.config.analytics.learn_more")}
</ha-button>
</div>
`;
}
@@ -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 {
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: event.detail.preferences,
};
this._save();
}
static get styles(): CSSResultGroup {
@@ -165,10 +117,21 @@ class ConfigAnalytics extends LitElement {
p {
margin-top: 0;
}
ha-card:not(:first-of-type) {
margin-top: 24px;
.card-actions {
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,
fetchHassioHostInfo,
} from "../../../data/hassio/host";
import type { LabPreviewFeature } from "../../../data/labs";
import { fetchLabFeatures } from "../../../data/labs";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
@@ -50,6 +52,8 @@ class HaConfigSystemNavigation extends LitElement {
@state() private _externalAccess = false;
@state() private _labFeatures?: LabPreviewFeature[];
protected render(): TemplateResult {
const pages = configSections.general
.filter((page) => canShowPage(this.hass, page))
@@ -94,6 +98,12 @@ class HaConfigSystemNavigation extends LitElement {
this._boardName ||
this.hass.localize("ui.panel.config.hardware.description");
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:
description = this.hass.localize(
@@ -156,6 +166,7 @@ class HaConfigSystemNavigation extends LitElement {
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
this._fetchBackupInfo();
this._fetchHardwareInfo(isHassioLoaded);
this._fetchLabFeatures();
if (isHassioLoaded) {
this._fetchStorageInfo();
}
@@ -211,6 +222,12 @@ class HaConfigSystemNavigation extends LitElement {
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() {
showRestartDialog(this);
}

View File

@@ -281,8 +281,12 @@ class DialogNewDashboard extends LitElement implements HassDialog {
@media all and (min-width: 850px) {
ha-dialog {
--mdc-dialog-min-width: 845px;
--mdc-dialog-min-height: calc(100vh - 72px);
--mdc-dialog-max-height: calc(100vh - 72px);
--mdc-dialog-min-height: calc(
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 "../../../../components/ha-alert";
import type { EnergyValidationIssue } from "../../../../data/energy";
import { documentationUrl } from "../../../../util/documentation-url";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-energy-validation-result")
@@ -29,7 +30,10 @@ class EnergyValidationMessage extends LitElement {
)}
${issue.type === "recorder_untracked"
? html`(<a
href="https://www.home-assistant.io/integrations/recorder#configure-filter"
href=${documentationUrl(
this.hass,
"/integrations/recorder#configure-filter"
)}
target="_blank"
rel="noopener noreferrer"
>${this.hass.localize("ui.panel.config.common.learn_more")}</a

View File

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

View File

@@ -462,7 +462,7 @@ class AddIntegrationDialog extends LitElement {
style=${styleMap({
width: `${this._width}px`,
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",
})}
@click=${this._integrationPicked}

View File

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

View File

@@ -302,7 +302,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_unload &&
item.source !== "system"
? 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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
@@ -311,14 +311,14 @@ class HaConfigEntryRow extends LitElement {
`
: 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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.copy"
@@ -328,7 +328,7 @@ class HaConfigEntryRow extends LitElement {
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.clickAction=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
@@ -360,7 +360,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_reconfigure &&
item.source !== "system"
? 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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
@@ -369,7 +369,10 @@ class HaConfigEntryRow extends LitElement {
`
: 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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
@@ -377,7 +380,7 @@ class HaConfigEntryRow extends LitElement {
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-md-menu-item .clickAction=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
@@ -389,7 +392,7 @@ class HaConfigEntryRow extends LitElement {
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
.clickAction=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
@@ -403,7 +406,10 @@ class HaConfigEntryRow extends LitElement {
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-md-menu-item
class="warning"
.clickAction=${this._handleDelete}
>
<ha-svg-icon
slot="start"
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 locale_key = result.require_restart
? "reload_restart_confirm"
@@ -621,9 +627,9 @@ class HaConfigEntryRow extends LitElement {
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
}
};
private async _handleReconfigure() {
private _handleReconfigure = async () => {
showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
@@ -631,18 +637,18 @@ class HaConfigEntryRow extends LitElement {
entryId: this.entry.entry_id,
navigateToResult: true,
});
}
};
private async _handleCopy() {
private _handleCopy = async () => {
await copyToClipboard(this.entry.entry_id);
showToast(this, {
message:
this.hass?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard",
});
}
};
private async _handleRename() {
private _handleRename = async () => {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title,
@@ -656,7 +662,7 @@ class HaConfigEntryRow extends LitElement {
await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName,
});
}
};
private async _signUrl(ev) {
const anchor = ev.currentTarget;
@@ -668,7 +674,7 @@ class HaConfigEntryRow extends LitElement {
fileDownload(signedUrl.path);
}
private async _handleDisable() {
private _handleDisable = async () => {
const entryId = this.entry.entry_id;
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;
let result: DisableConfigEntryResult;
@@ -731,9 +737,9 @@ class HaConfigEntryRow extends LitElement {
),
});
}
}
};
private async _handleDelete() {
private _handleDelete = async () => {
const entryId = this.entry.entry_id;
const applicationCredentialsId =
@@ -767,20 +773,20 @@ class HaConfigEntryRow extends LitElement {
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
}
};
private _handleSystemOptions() {
private _handleSystemOptions = () => {
showConfigEntrySystemOptionsDialog(this, {
entry: this.entry,
manifest: this.manifest,
});
}
};
private _addSubEntry(ev) {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
private _addSubEntry = (item) => {
showSubConfigFlowDialog(this, this.entry, item.flowType, {
startFlowHandler: this.entry.entry_id,
});
}
};
static styles = [
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 { LitElement, css, html } from "lit";
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-menu";
import "../../../components/ha-list-item";
import {
deleteApplicationCredential,
fetchApplicationCredentialsConfigEntry,
} from "../../../data/application_credential";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
ATTENTION_SOURCES,
DISCOVERY_SOURCES,
@@ -15,7 +26,10 @@ import {
} from "../../../data/config_flow";
import type { IntegrationManifest } from "../../../data/integration";
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 { documentationUrl } from "../../../util/documentation-url";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
@@ -60,7 +74,7 @@ export class HaConfigFlowCard extends LitElement {
: "ui.common.add"
)}
</ha-button>
${this.flow.context.configuration_url || this.manifest
${this.flow.context.configuration_url || this.manifest || attention
? html`<ha-button-menu slot="header-button">
<ha-icon-button
slot="trigger"
@@ -118,6 +132,22 @@ export class HaConfigFlowCard extends LitElement {
</ha-list-item>
</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-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`
a {
text-decoration: none;
@@ -191,6 +324,9 @@ export class HaConfigFlowCard extends LitElement {
--mdc-theme-primary: 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>
`
: 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>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</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
slot="start"
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, {
title: this.hass.localize("ui.common.rename"),
defaultValue: this.subEntry.title,
@@ -239,9 +242,9 @@ class HaConfigSubEntryRow extends LitElement {
this.subEntry.subentry_id,
{ title: newName }
);
}
};
private async _handleDeleteSub(): Promise<void> {
private _handleDeleteSub = async (): Promise<void> => {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
@@ -263,7 +266,7 @@ class HaConfigSubEntryRow extends LitElement {
this.entry.entry_id,
this.subEntry.subentry_id
);
}
};
static styles = css`
.expand-button {

View File

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

View File

@@ -295,7 +295,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
color:
route.route_status === "Active"
? primaryColor
: style.getPropertyValue("--disabled-color"),
: style.getPropertyValue("--dark-primary-color"),
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: "dotted",
@@ -335,7 +335,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--disabled-color"),
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
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 { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL, setDefaultPanel } from "../../../../data/panel";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
@customElement("dialog-lovelace-dashboard-detail")
@@ -59,7 +61,8 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params || !this._data) {
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();
return html`
@@ -251,15 +254,38 @@ export class DialogLovelaceDashboardDetail extends LitElement {
};
}
private _toggleDefault() {
private async _toggleDefault() {
const urlPath = this._params?.urlPath;
if (!urlPath) {
return;
}
setDefaultPanel(
this,
urlPath === this.hass.defaultPanel ? DEFAULT_PANEL : urlPath
);
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
// 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() {

View File

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

View File

@@ -1112,6 +1112,9 @@ ${rejected
private async _delete(scene: SceneEntity): Promise<void> {
if (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;
margin-bottom: 24px;
}
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
ha-fab.dirty {
bottom: 0;
}

View File

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

View File

@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { documentationUrl } from "../../../util/documentation-url";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog";
@@ -14,8 +15,6 @@ import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { TagDetailDialogParams } from "./show-dialog-tag-detail";
const TAG_BASE = "https://www.home-assistant.io/tag/";
@customElement("dialog-tag-detail")
class DialogTagDetail
extends LitElement
@@ -122,7 +121,7 @@ class DialogTagDetail
</div>
<div id="qr">
<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"
error-correction-level="quartile"
scale="5"

View File

@@ -61,7 +61,7 @@ class HaPanelDevStateRenderer extends LitElement {
protected render() {
const showAttributes = !this.narrow && this.showAttributes;
return html`
<div
<div
class=${classMap({ entities: true, "hide-attributes": !showAttributes })}
role="table"
>
@@ -245,6 +245,7 @@ class HaPanelDevStateRenderer extends LitElement {
:host([virtualize]) {
display: block;
height: 100%;
overflow: auto;
}
.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 { mdiPencil, mdiDownload } from "@mdi/js";
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-list-item";
import "../../components/ha-top-app-bar-fixed";
import "../../components/ha-alert";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -21,6 +22,7 @@ import type {
GasSourceTypeEnergyPreference,
WaterSourceTypeEnergyPreference,
DeviceConsumptionEnergyPreference,
EnergyCollection,
} from "../../data/energy";
import {
computeConsumptionData,
@@ -30,13 +32,28 @@ import {
import { fileDownload } from "../../util/file_download";
import type { StatisticValue } from "../../data/recorder";
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
const ENERGY_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
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;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@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) {
this.hass.loadFragmentTranslation("lovelace");
}
@@ -62,22 +96,71 @@ class PanelEnergy extends LitElement {
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
this._setLovelace();
}
if (oldHass && oldHass.localize !== this.hass.localize) {
} else if (oldHass && oldHass.localize !== this.hass.localize) {
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) {
ev.stopPropagation();
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`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
${showBack
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
@@ -99,7 +182,7 @@ class PanelEnergy extends LitElement {
<hui-energy-period-selector
.hass=${this.hass}
collection-key="energy_dashboard"
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
>
${this.hass.user?.is_admin
? html` <ha-list-item
@@ -127,12 +210,21 @@ class PanelEnergy extends LitElement {
.hass=${this.hass}
@reload-energy-panel=${this._reloadView}
>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></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}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${viewIndex}
></hui-view>`
: nothing}
</hui-view-container>
`;
}
@@ -160,9 +252,7 @@ class PanelEnergy extends LitElement {
private async _dumpCSV(ev) {
ev.stopPropagation();
const energyData = getEnergyDataCollection(this.hass, {
key: "energy_dashboard",
});
const energyData = this._energyCollection!;
if (!energyData.prefs || !energyData.state.stats) {
return;
@@ -459,11 +549,11 @@ class PanelEnergy extends LitElement {
}
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;
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%;
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 { customElement } from "lit/decorators";
import type {
EnergyPreferences,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { getEnergyPreferences } from "../../../data/energy";
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 { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
const setupWizard = async (): Promise<LovelaceViewConfig> => {
await import("../cards/energy-setup-wizard-card");
return {
type: "panel",
cards: [
{
type: "custom:energy-setup-wizard-card",
},
],
};
};
@customElement("energy-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
@customElement("energy-electricity-view-strategy")
export class EnergyElectricityViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = { cards: [] };
let prefs: EnergyPreferences;
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
try {
prefs = await getEnergyPreferences(hass);
} 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 energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
const prefs = energyCollection.prefs;
// No energy sources available, start from scratch
// No energy sources available
if (
prefs!.device_consumption.length === 0 &&
prefs!.energy_sources.length === 0
!prefs ||
(prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0)
) {
return setupWizard();
return view;
}
view.type = "sidebar";
@@ -63,12 +43,17 @@ export class EnergyViewStrategy extends ReactiveElement {
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
);
view.cards!.push({
@@ -76,6 +61,24 @@ export class EnergyViewStrategy extends ReactiveElement {
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.
if (hasGrid || hasBattery) {
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.
if (hasGrid || hasBattery) {
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({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
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.
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(
(d) => d.included_in_stat
);
@@ -194,6 +166,20 @@ export class EnergyViewStrategy extends ReactiveElement {
group_by_floor: 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;
@@ -202,6 +188,6 @@ export class EnergyViewStrategy extends ReactiveElement {
declare global {
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 { debounce } from "../../common/util/debounce";
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 { HomeAssistant, PanelInfo, Route } from "../../types";
import { showToast } from "../../util/toast";
import "../lovelace/hui-root";
import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showAlertDialog } from "../lovelace/custom-card-helpers";
const HOME_LOVELACE_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",
},
};
import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
@customElement("ha-panel-home")
class PanelHome extends LitElement {
@@ -28,12 +28,14 @@ class PanelHome extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config: FrontendSystemData["home"] = {};
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._loadConfig();
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() {
const strategyConfig: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",
favorite_entities: this._config.favorite_entities,
},
};
const config = await generateLovelaceDashboardStrategy(
HOME_LOVELACE_CONFIG,
strategyConfig,
this.hass
);
@@ -121,15 +142,34 @@ class PanelHome extends LitElement {
}
private _setEditMode = () => {
// For now, we just show an alert that edit mode is not supported.
// This will be expanded in the future.
showAlertDialog(this, {
title: "Edit mode not available",
text: "The Home panel does not support edit mode.",
confirmText: this.hass.localize("ui.common.ok"),
showEditHomeDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
});
};
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`
:host {
display: block;

View File

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

View File

@@ -1,8 +1,9 @@
import { css, LitElement, nothing, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../common/number/format_number";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type {
LovelaceCardFeatureContext,
BarGaugeCardFeatureConfig,
@@ -17,7 +18,7 @@ export const supportsBarGaugeCardFeature = (
: undefined;
if (!stateObj) return false;
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")
@@ -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 {
if (!config) {
throw new Error("Invalid configuration");
@@ -53,8 +59,20 @@ class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
return nothing;
}
const stateObj = this.hass.states[this.context.entity_id];
const value = stateObj.state;
return html`<div style="width: ${value}%"></div>
const min = this._config.min ?? 0;
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>`;
}

View File

@@ -124,16 +124,24 @@ class HuiHistoryChartCardFeature
}
const hourToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const detail = this._config.detail !== false; // default to true (high detail)
return subscribeHistoryStatesTimeWindow(
this.hass!,
(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 } =
coordinatesMinimalResponseCompressedState(
historyStates[this.context!.entity_id!],
this.clientWidth,
this.clientHeight,
this.clientWidth / 5 // sample to 1 point per 5 pixels
maxDetails,
undefined,
useMean
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;

View File

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

View File

@@ -203,7 +203,7 @@ function formatTooltip(
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);
let footer = "";

View File

@@ -135,11 +135,13 @@ export class HuiEnergyDevicesGraphCard
return nothing;
}
const modes = this._getAllowedModes();
return html`
<ha-card>
<div class="card-header">
<span>${this._config.title ? this._config.title : nothing}</span>
${this._getAllowedModes().length > 1
${modes.length > 1
? html`
<ha-icon-button
.path=${this._chartType === "pie"
@@ -166,7 +168,7 @@ export class HuiEnergyDevicesGraphCard
this._chartType,
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]}
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@@ -185,7 +187,7 @@ export class HuiEnergyDevicesGraphCard
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} 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(
@@ -492,7 +494,7 @@ export class HuiEnergyDevicesGraphCard
show: true,
position: "center",
color: computedStyle.getPropertyValue("--secondary-text-color"),
fontSize: computedStyle.getPropertyValue("--ha-font-size-l"),
fontSize: computedStyle.getPropertyValue("--ha-font-size-m"),
lineHeight: 24,
fontWeight: "bold",
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 { 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 } from "../../../../data/energy";
@@ -38,6 +39,8 @@ class HuiEnergySankeyCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: EnergySankeyCardConfig;
@state() private _data?: EnergyData;
@@ -385,7 +388,14 @@ class HuiEnergySankeyCard
(this._config.layout !== "horizontal" && this._isMobileSize);
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">
${hasData
? html`<ha-sankey-chart
@@ -402,7 +412,9 @@ class HuiEnergySankeyCard
}
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[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
@@ -508,17 +520,18 @@ class HuiEnergySankeyCard
}
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 {
height: 100%;
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;

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 type { ECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@customElement("hui-power-sources-graph-card")
export class HuiPowerSourcesGraphCard
@@ -33,6 +34,8 @@ export class HuiPowerSourcesGraphCard
@state() private _chartData: LineSeriesOption[] = [];
@state() private _legendData?: CustomLegendOption["data"];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@@ -91,7 +94,8 @@ export class HuiPowerSourcesGraphCard
this.hass.locale,
this.hass.config,
this._compareStart,
this._compareEnd
this._compareEnd,
this._legendData
)}
></ha-chart-base>
${!this._chartData.some((dataset) => dataset.data!.length)
@@ -115,9 +119,10 @@ export class HuiPowerSourcesGraphCard
locale: FrontendLocaleData,
config: HassConfig,
compareStart?: Date,
compareEnd?: Date
): ECOption =>
getCommonOptions(
compareEnd?: Date,
legendData?: CustomLegendOption["data"]
): ECOption => ({
...getCommonOptions(
start,
end,
locale,
@@ -125,11 +130,18 @@ export class HuiPowerSourcesGraphCard
"kW",
compareStart,
compareEnd
)
),
legend: {
show: true,
type: "custom",
data: legendData,
},
})
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: LineSeriesOption[] = [];
this._legendData = [];
const statIds = {
solar: {
@@ -238,6 +250,15 @@ export class HuiPowerSourcesGraphCard
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(
"ui.panel.lovelace.cards.energy.power_graph.usage"
),
color: computedStyles.getPropertyValue("--primary-color"),
lineStyle: { width: 2 },
color: computedStyles.getPropertyValue("--primary-text-color"),
lineStyle: {
type: [7, 2],
width: 1.5,
},
data: usageData,
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[][]) {

View File

@@ -150,11 +150,6 @@ export interface EnergyCardBaseConfig extends LovelaceCardConfig {
collection_key?: string;
}
export interface EnergySummaryCardConfig extends EnergyCardBaseConfig {
type: "energy-summary";
title?: string;
}
export interface EnergyDistributionCardConfig extends EnergyCardBaseConfig {
type: "energy-distribution";
title?: string;
@@ -236,6 +231,14 @@ export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
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 {
type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[];

View File

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

View File

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

View File

@@ -184,19 +184,15 @@ export class HuiCreateDialogCard
return [
haStyleDialog,
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) {
ha-dialog {
--mdc-dialog-min-width: 845px;
--mdc-dialog-min-height: calc(100vh - 72px);
--mdc-dialog-max-height: calc(100vh - 72px);
--mdc-dialog-min-height: calc(
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";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
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 { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
@@ -371,6 +374,7 @@ export class HuiDialogEditCard
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
:host {
--code-mirror-max-height: calc(100vh - 176px);
@@ -379,8 +383,6 @@ export class HuiDialogEditCard
ha-dialog {
--mdc-dialog-max-width: 100px;
--dialog-z-index: 6;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-width: 90vw;
--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>([
"alarm-modes",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-hvac-modes",

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