mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-24 18:27:19 +00:00
Compare commits
42 Commits
water_devi
...
add-condit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a176f4b6e6 | ||
|
|
abb547d7ae | ||
|
|
14a1614d81 | ||
|
|
232d3d5637 | ||
|
|
4674384f7d | ||
|
|
2e7c95a8cc | ||
|
|
9630b175a1 | ||
|
|
be319503f7 | ||
|
|
9b1fe28018 | ||
|
|
0595f722f3 | ||
|
|
1c0315854a | ||
|
|
3b73d7c298 | ||
|
|
2955cb4956 | ||
|
|
c679e312a0 | ||
|
|
37e12a83be | ||
|
|
755c6dbb93 | ||
|
|
4a90331ac7 | ||
|
|
7b264ae338 | ||
|
|
bb5fefce2b | ||
|
|
5703de9616 | ||
|
|
eee2c1e8fd | ||
|
|
d4c1642ccc | ||
|
|
0d693e692a | ||
|
|
c6091971b6 | ||
|
|
60229ceba0 | ||
|
|
e45b631e27 | ||
|
|
ea798cda90 | ||
|
|
259f4421db | ||
|
|
1ac3cf199f | ||
|
|
8d96679cc3 | ||
|
|
ba9c7f3012 | ||
|
|
f8923ed648 | ||
|
|
20d0548d33 | ||
|
|
04aaae20f5 | ||
|
|
852dbbeee0 | ||
|
|
d57367f62e | ||
|
|
47a107dd85 | ||
|
|
3573e823e4 | ||
|
|
1a8319a3ab | ||
|
|
ac23ce6300 | ||
|
|
fc38365958 | ||
|
|
b93bf3bc4b |
183
.github/copilot-instructions.md
vendored
183
.github/copilot-instructions.md
vendored
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -84,7 +84,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.energy_boiler",
|
||||
},
|
||||
],
|
||||
device_consumption_water: [],
|
||||
})
|
||||
);
|
||||
hass.mockWS(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
53
src/common/areas/areas-floor-hierarchy.ts
Normal file
53
src/common/areas/areas-floor-hierarchy.ts
Normal 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);
|
||||
67
src/common/keyboard/shortcuts.ts
Normal file
67
src/common/keyboard/shortcuts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/components/ha-condition-icon.ts
Normal file
84
src/components/ha-condition-icon.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
mdiAmpersand,
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiGateOr,
|
||||
mdiIdentifier,
|
||||
mdiMapMarkerRadius,
|
||||
mdiNotEqualVariant,
|
||||
mdiNumeric,
|
||||
mdiStateMachine,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
export const CONDITION_ICONS = {
|
||||
device: mdiDevices,
|
||||
and: mdiAmpersand,
|
||||
or: mdiGateOr,
|
||||
not: mdiNotEqualVariant,
|
||||
state: mdiStateMachine,
|
||||
numeric_state: mdiNumeric,
|
||||
sun: mdiWeatherSunny,
|
||||
template: mdiCodeBraces,
|
||||
time: mdiClockOutline,
|
||||
trigger: mdiIdentifier,
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
|
||||
@customElement("ha-condition-icon")
|
||||
export class HaConditionIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public condition?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
}
|
||||
|
||||
if (!this.condition) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
});
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
private _renderFallback() {
|
||||
const domain = computeDomain(this.condition!);
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${CONDITION_ICONS[this.condition!] ||
|
||||
FALLBACK_DOMAIN_ICONS[domain]}
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-condition-icon": HaConditionIcon;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { createSearchParam } from "../common/url/search-params";
|
||||
import type { Context, HomeAssistant } from "../types";
|
||||
import type { BlueprintInput } from "./blueprint";
|
||||
import type { ConditionDescription } from "./condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "./condition";
|
||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||
import type { Action, Field, MODES } from "./script";
|
||||
@@ -236,6 +237,12 @@ interface BaseCondition {
|
||||
condition: string;
|
||||
alias?: string;
|
||||
enabled?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PlatformCondition extends BaseCondition {
|
||||
condition: Exclude<string, LegacyCondition["condition"]>;
|
||||
target?: HassServiceTarget;
|
||||
}
|
||||
|
||||
export interface LogicalCondition extends BaseCondition {
|
||||
@@ -320,7 +327,7 @@ export type AutomationElementGroup = Record<
|
||||
{ icon?: string; members?: AutomationElementGroup }
|
||||
>;
|
||||
|
||||
export type Condition =
|
||||
export type LegacyCondition =
|
||||
| StateCondition
|
||||
| NumericStateCondition
|
||||
| SunCondition
|
||||
@@ -331,6 +338,8 @@ export type Condition =
|
||||
| LogicalCondition
|
||||
| TriggerCondition;
|
||||
|
||||
export type Condition = LegacyCondition | PlatformCondition;
|
||||
|
||||
export type ConditionWithShorthand =
|
||||
| Condition
|
||||
| ShorthandAndConditionList
|
||||
@@ -608,6 +617,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig {
|
||||
insertAfter: (value: Condition | Condition[]) => boolean;
|
||||
toggleYamlMode: () => void;
|
||||
config: Condition;
|
||||
description?: ConditionDescription;
|
||||
yamlMode: boolean;
|
||||
uiSupported: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
} from "../common/string/format-list";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
|
||||
import type {
|
||||
Condition,
|
||||
ForDict,
|
||||
LegacyCondition,
|
||||
LegacyTrigger,
|
||||
Trigger,
|
||||
} from "./automation";
|
||||
import { getConditionDomain, getConditionObjectId } from "./condition";
|
||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||
import {
|
||||
localizeDeviceAutomationCondition,
|
||||
@@ -896,6 +903,39 @@ const tryDescribeCondition = (
|
||||
}
|
||||
}
|
||||
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
|
||||
const conditionType = condition.condition;
|
||||
|
||||
const domain = getConditionDomain(condition.condition);
|
||||
const type = getConditionObjectId(condition.condition);
|
||||
|
||||
return (
|
||||
hass.localize(
|
||||
`component.${domain}.conditions.${type}.description_configured`
|
||||
) ||
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label`
|
||||
) ||
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.unknown_condition`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
|
||||
@@ -1287,12 +1327,5 @@ const tryDescribeCondition = (
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${condition.condition}.label`
|
||||
) ||
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.unknown_condition`
|
||||
)
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
import {
|
||||
mdiAmpersand,
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiGateOr,
|
||||
mdiIdentifier,
|
||||
mdiMapClock,
|
||||
mdiMapMarkerRadius,
|
||||
mdiNotEqualVariant,
|
||||
mdiNumeric,
|
||||
mdiShape,
|
||||
mdiStateMachine,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { AutomationElementGroupCollection } from "./automation";
|
||||
|
||||
export const CONDITION_ICONS = {
|
||||
device: mdiDevices,
|
||||
and: mdiAmpersand,
|
||||
or: mdiGateOr,
|
||||
not: mdiNotEqualVariant,
|
||||
state: mdiStateMachine,
|
||||
numeric_state: mdiNumeric,
|
||||
sun: mdiWeatherSunny,
|
||||
template: mdiCodeBraces,
|
||||
time: mdiClockOutline,
|
||||
trigger: mdiIdentifier,
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
import type { Selector, TargetSelector } from "./selector";
|
||||
|
||||
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
dynamicGroups: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
@@ -62,3 +39,33 @@ export const COLLAPSIBLE_CONDITION_ELEMENTS = [
|
||||
"ha-automation-condition-not",
|
||||
"ha-automation-condition-or",
|
||||
];
|
||||
|
||||
export interface ConditionDescription {
|
||||
target?: TargetSelector["target"];
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
example?: string | boolean | number;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
selector?: Selector;
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export type ConditionDescriptions = Record<string, ConditionDescription>;
|
||||
|
||||
export const subscribeConditions = (
|
||||
hass: HomeAssistant,
|
||||
callback: (conditions: ConditionDescriptions) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
|
||||
type: "condition_platforms/subscribe",
|
||||
});
|
||||
|
||||
export const getConditionDomain = (condition: string) =>
|
||||
condition.includes(".") ? computeDomain(condition) : condition;
|
||||
|
||||
export const getConditionObjectId = (condition: string) =>
|
||||
condition.includes(".") ? computeObjectId(condition) : "_";
|
||||
|
||||
@@ -200,7 +200,6 @@ export type EnergySource =
|
||||
export interface EnergyPreferences {
|
||||
energy_sources: EnergySource[];
|
||||
device_consumption: DeviceConsumptionEnergyPreference[];
|
||||
device_consumption_water: DeviceConsumptionEnergyPreference[];
|
||||
}
|
||||
|
||||
export interface EnergyInfo {
|
||||
@@ -217,7 +216,6 @@ export interface EnergyValidationIssue {
|
||||
export interface EnergyPreferencesValidation {
|
||||
energy_sources: EnergyValidationIssue[][];
|
||||
device_consumption: EnergyValidationIssue[][];
|
||||
device_consumption_water: EnergyValidationIssue[][];
|
||||
}
|
||||
|
||||
export const getEnergyInfo = (hass: HomeAssistant) =>
|
||||
@@ -358,11 +356,6 @@ export const getReferencedStatisticIds = (
|
||||
if (!(includeTypes && !includeTypes.includes("device"))) {
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_consumption));
|
||||
}
|
||||
if (!(includeTypes && !includeTypes.includes("water"))) {
|
||||
statIDs.push(
|
||||
...prefs.device_consumption_water.map((d) => d.stat_consumption)
|
||||
);
|
||||
}
|
||||
|
||||
return statIDs;
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
|
||||
import { getConditionDomain, getConditionObjectId } from "./condition";
|
||||
|
||||
/** Icon to use when no icon specified for service. */
|
||||
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
||||
@@ -138,15 +139,25 @@ const resources: {
|
||||
all?: Promise<Record<string, TriggerIcons>>;
|
||||
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
|
||||
};
|
||||
conditions: {
|
||||
all?: Promise<Record<string, ConditionIcons>>;
|
||||
domains: Record<string, ConditionIcons | Promise<ConditionIcons>>;
|
||||
};
|
||||
} = {
|
||||
entity: {},
|
||||
entity_component: {},
|
||||
services: { domains: {} },
|
||||
triggers: { domains: {} },
|
||||
conditions: { domains: {} },
|
||||
};
|
||||
|
||||
interface IconResources<
|
||||
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
|
||||
T extends
|
||||
| ComponentIcons
|
||||
| PlatformIcons
|
||||
| ServiceIcons
|
||||
| TriggerIcons
|
||||
| ConditionIcons,
|
||||
> {
|
||||
resources: Record<string, T>;
|
||||
}
|
||||
@@ -195,17 +206,24 @@ type TriggerIcons = Record<
|
||||
{ trigger: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
type ConditionIcons = Record<
|
||||
string,
|
||||
{ condition: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
export type IconCategory =
|
||||
| "entity"
|
||||
| "entity_component"
|
||||
| "services"
|
||||
| "triggers";
|
||||
| "triggers"
|
||||
| "conditions";
|
||||
|
||||
interface CategoryType {
|
||||
entity: PlatformIcons;
|
||||
entity_component: ComponentIcons;
|
||||
services: ServiceIcons;
|
||||
triggers: TriggerIcons;
|
||||
conditions: ConditionIcons;
|
||||
}
|
||||
|
||||
export const getHassIcons = async <T extends IconCategory>(
|
||||
@@ -327,6 +345,13 @@ export const getTriggerIcons = async (
|
||||
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "triggers", domain, force);
|
||||
|
||||
export const getConditionIcons = async (
|
||||
hass: HomeAssistant,
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "conditions", domain, force);
|
||||
|
||||
// Cache for sorted range keys
|
||||
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
|
||||
|
||||
@@ -526,6 +551,25 @@ export const triggerIcon = async (
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const conditionIcon = async (
|
||||
hass: HomeAssistant,
|
||||
condition: string
|
||||
): Promise<string | undefined> => {
|
||||
let icon: string | undefined;
|
||||
|
||||
const domain = getConditionDomain(condition);
|
||||
const conditionIcons = await getConditionIcons(hass, domain);
|
||||
if (conditionIcons) {
|
||||
const conditionName = getConditionObjectId(condition);
|
||||
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
|
||||
icon = condIcon?.condition;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = await domainIcon(hass, domain);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const serviceIcon = async (
|
||||
hass: HomeAssistant,
|
||||
service: string
|
||||
|
||||
78
src/data/labs.ts
Normal file
78
src/data/labs.ts
Normal 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
|
||||
);
|
||||
@@ -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") {
|
||||
|
||||
@@ -72,9 +72,11 @@ export type TranslationCategory =
|
||||
| "system_health"
|
||||
| "application_credentials"
|
||||
| "issues"
|
||||
| "preview_features"
|
||||
| "selector"
|
||||
| "services"
|
||||
| "triggers";
|
||||
| "triggers"
|
||||
| "conditions";
|
||||
|
||||
export const subscribeTranslationPreferences = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { stringCompare } from "../../../../../common/string/compare";
|
||||
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon";
|
||||
import "../../../../../components/ha-list-item";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelect } from "../../../../../components/ha-select";
|
||||
import type { Condition } from "../../../../../data/automation";
|
||||
import {
|
||||
DYNAMIC_PREFIX,
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type Condition,
|
||||
} from "../../../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../../../data/condition";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
CONDITION_ICONS,
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
subscribeConditions,
|
||||
} from "../../../../../data/condition";
|
||||
import type { Entries, HomeAssistant } from "../../../../../types";
|
||||
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import "../../condition/ha-automation-condition-editor";
|
||||
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
|
||||
import "../../condition/types/ha-automation-condition-and";
|
||||
@@ -30,7 +40,10 @@ import "../../condition/types/ha-automation-condition-zone";
|
||||
import type { ActionElement } from "../ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action-condition")
|
||||
export class HaConditionAction extends LitElement implements ActionElement {
|
||||
export class HaConditionAction
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements ActionElement
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@@ -43,6 +56,8 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "indent" }) public indent = false;
|
||||
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@query("ha-automation-condition-editor")
|
||||
private _conditionEditor?: HaAutomationConditionEditor;
|
||||
|
||||
@@ -50,6 +65,21 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
return { condition: "state" };
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeConditions(this.hass, (conditions) =>
|
||||
this._addConditions(conditions)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _addConditions(conditions: ConditionDescriptions) {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
|
||||
this.action.condition
|
||||
@@ -64,19 +94,25 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
"ui.panel.config.automation.editor.conditions.type_select"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this.action.condition}
|
||||
.value=${this.action.condition in this._conditionDescriptions
|
||||
? `${DYNAMIC_PREFIX}${this.action.condition}`
|
||||
: this.action.condition}
|
||||
naturalMenuWidth
|
||||
@selected=${this._typeChanged}
|
||||
@closed=${stopPropagation}
|
||||
>
|
||||
${this._processedTypes(this.hass.localize).map(
|
||||
([opt, label, icon]) => html`
|
||||
${this._processedTypes(
|
||||
this._conditionDescriptions,
|
||||
this.hass.localize
|
||||
).map(
|
||||
([opt, label, condition]) => html`
|
||||
<ha-list-item .value=${opt} graphic="icon">
|
||||
${label}<ha-svg-icon
|
||||
${label}
|
||||
<ha-condition-icon
|
||||
slot="graphic"
|
||||
.path=${icon}
|
||||
></ha-svg-icon
|
||||
></ha-list-item>
|
||||
.condition=${condition}
|
||||
></ha-condition-icon>
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-select>
|
||||
@@ -88,11 +124,14 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
? html`
|
||||
<ha-automation-condition-editor
|
||||
.condition=${this.action}
|
||||
.description=${this._conditionDescriptions[this.action.condition]}
|
||||
.disabled=${this.disabled}
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._conditionChanged}
|
||||
.narrow=${this.narrow}
|
||||
.uiSupported=${this._uiSupported(this.action.condition)}
|
||||
.uiSupported=${this._uiSupported(
|
||||
this._getType(this.action, this._conditionDescriptions)
|
||||
)}
|
||||
.indent=${this.indent}
|
||||
action
|
||||
></ha-automation-condition-editor>
|
||||
@@ -102,19 +141,46 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
}
|
||||
|
||||
private _processedTypes = memoizeOne(
|
||||
(localize: LocalizeFunc): [string, string, string][] =>
|
||||
(Object.entries(CONDITION_ICONS) as Entries<typeof CONDITION_ICONS>)
|
||||
.map(
|
||||
([condition, icon]) =>
|
||||
[
|
||||
condition,
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
|
||||
),
|
||||
icon,
|
||||
] as [string, string, string]
|
||||
)
|
||||
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
|
||||
(
|
||||
conditionDescriptions: ConditionDescriptions,
|
||||
localize: LocalizeFunc
|
||||
): [string, string, string][] => {
|
||||
const legacy = (
|
||||
Object.keys(CONDITION_ICONS) as (keyof typeof CONDITION_ICONS)[]
|
||||
).map(
|
||||
(condition) =>
|
||||
[
|
||||
condition,
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
|
||||
),
|
||||
condition,
|
||||
] as [string, string, string]
|
||||
);
|
||||
const platform = Object.keys(conditionDescriptions).map((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
const conditionObjId = getConditionObjectId(condition);
|
||||
return [
|
||||
`${DYNAMIC_PREFIX}${condition}`,
|
||||
localize(`component.${domain}.conditions.${conditionObjId}.name`) ||
|
||||
condition,
|
||||
condition,
|
||||
] as [string, string, string];
|
||||
});
|
||||
return [...legacy, ...platform].sort((a, b) =>
|
||||
stringCompare(a[1], b[1], this.hass.locale.language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _getType = memoizeOne(
|
||||
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
|
||||
if (condition.condition in conditionDescriptions) {
|
||||
return "platform";
|
||||
}
|
||||
|
||||
return condition.condition;
|
||||
}
|
||||
);
|
||||
|
||||
private _conditionChanged(ev: CustomEvent) {
|
||||
@@ -132,6 +198,18 @@ export class HaConditionAction extends LitElement implements ActionElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDynamic(type)) {
|
||||
const value = getValueFromDynamic(type);
|
||||
if (value !== this.action.condition) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
condition: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const elClass = customElements.get(
|
||||
`ha-automation-condition-${type}`
|
||||
) as CustomElementConstructor & {
|
||||
|
||||
@@ -56,12 +56,19 @@ import {
|
||||
type AutomationElementGroup,
|
||||
type AutomationElementGroupCollection,
|
||||
} from "../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../data/condition";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS_GROUP,
|
||||
CONDITION_COLLECTIONS,
|
||||
CONDITION_ICONS,
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
subscribeConditions,
|
||||
} from "../../../data/condition";
|
||||
import { getServiceIcons, getTriggerIcons } from "../../../data/icons";
|
||||
import {
|
||||
getConditionIcons,
|
||||
getServiceIcons,
|
||||
getTriggerIcons,
|
||||
} from "../../../data/icons";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
domainToName,
|
||||
@@ -82,6 +89,7 @@ import { isMac } from "../../../util/is_mac";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
|
||||
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
|
||||
import { CONDITION_ICONS } from "../../../components/ha-condition-icon";
|
||||
|
||||
const TYPES = {
|
||||
trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS },
|
||||
@@ -119,7 +127,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
|
||||
|
||||
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
|
||||
|
||||
const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"];
|
||||
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "other"];
|
||||
|
||||
@customElement("add-automation-element-dialog")
|
||||
class DialogAddAutomationElement
|
||||
@@ -152,6 +160,8 @@ class DialogAddAutomationElement
|
||||
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@query(".items ha-md-list ha-md-list-item")
|
||||
private _itemsListFirstElement?: HaMdList;
|
||||
|
||||
@@ -169,15 +179,15 @@ class DialogAddAutomationElement
|
||||
|
||||
this.addKeyboardShortcuts();
|
||||
|
||||
this._unsubscribe();
|
||||
this._fetchManifests();
|
||||
|
||||
if (this._params?.type === "action") {
|
||||
this.hass.loadBackendTranslation("services");
|
||||
this._fetchManifests();
|
||||
this._calculateUsedDomains();
|
||||
getServiceIcons(this.hass);
|
||||
}
|
||||
if (this._params?.type === "trigger") {
|
||||
} else if (this._params?.type === "trigger") {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this._fetchManifests();
|
||||
getTriggerIcons(this.hass);
|
||||
this._unsub = subscribeTriggers(this.hass, (triggers) => {
|
||||
this._triggerDescriptions = {
|
||||
@@ -185,7 +195,17 @@ class DialogAddAutomationElement
|
||||
...triggers,
|
||||
};
|
||||
});
|
||||
} else if (this._params?.type === "condition") {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
getConditionIcons(this.hass);
|
||||
this._unsub = subscribeConditions(this.hass, (conditions) => {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this._fullScreen = matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
@@ -199,10 +219,7 @@ class DialogAddAutomationElement
|
||||
|
||||
public closeDialog() {
|
||||
this.removeKeyboardShortcuts();
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
this._unsubscribe();
|
||||
if (this._params) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
@@ -219,6 +236,13 @@ class DialogAddAutomationElement
|
||||
return true;
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _getGroups = (
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
group?: string,
|
||||
@@ -348,8 +372,11 @@ class DialogAddAutomationElement
|
||||
items.push(
|
||||
...this._triggers(localize, this._triggerDescriptions, manifests)
|
||||
);
|
||||
}
|
||||
if (type === "action") {
|
||||
} else if (type === "condition") {
|
||||
items.push(
|
||||
...this._conditions(localize, this._conditionDescriptions, manifests)
|
||||
);
|
||||
} else if (type === "action") {
|
||||
items.push(...this._services(localize, services, manifests));
|
||||
}
|
||||
return items;
|
||||
@@ -372,6 +399,7 @@ class DialogAddAutomationElement
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
triggerDescriptions: TriggerDescriptions,
|
||||
conditionDescriptions: ConditionDescriptions,
|
||||
manifests?: DomainManifestLookup
|
||||
): {
|
||||
titleKey?: LocalizeKeys;
|
||||
@@ -383,35 +411,10 @@ class DialogAddAutomationElement
|
||||
let collectionGroups = Object.entries(collection.groups);
|
||||
const groups: ListItem[] = [];
|
||||
|
||||
if (
|
||||
type === "action" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
ACTION_SERVICE_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
: "other"
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
type === "trigger" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
ACTION_SERVICE_KEYWORDS.includes(item)
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
@@ -429,7 +432,53 @@ class DialogAddAutomationElement
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
|
||||
([key]) => !DYNAMIC_KEYWORDS.includes(key)
|
||||
);
|
||||
} else if (
|
||||
type === "condition" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._conditionGroups(
|
||||
localize,
|
||||
conditionDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
: "other"
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !DYNAMIC_KEYWORDS.includes(key)
|
||||
);
|
||||
} else if (
|
||||
type === "action" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
DYNAMIC_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._serviceGroups(
|
||||
localize,
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
: "other"
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !DYNAMIC_KEYWORDS.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,10 +536,6 @@ class DialogAddAutomationElement
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup
|
||||
): ListItem[] => {
|
||||
if (type === "action" && isDynamic(group)) {
|
||||
return this._services(localize, services, manifests, group);
|
||||
}
|
||||
|
||||
if (type === "trigger" && isDynamic(group)) {
|
||||
return this._triggers(
|
||||
localize,
|
||||
@@ -499,6 +544,17 @@ class DialogAddAutomationElement
|
||||
group
|
||||
);
|
||||
}
|
||||
if (type === "condition" && isDynamic(group)) {
|
||||
return this._conditions(
|
||||
localize,
|
||||
this._conditionDescriptions,
|
||||
manifests,
|
||||
group
|
||||
);
|
||||
}
|
||||
if (type === "action" && isDynamic(group)) {
|
||||
return this._services(localize, services, manifests, group);
|
||||
}
|
||||
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
|
||||
@@ -688,6 +744,102 @@ class DialogAddAutomationElement
|
||||
}
|
||||
);
|
||||
|
||||
private _conditionGroups = (
|
||||
localize: LocalizeFunc,
|
||||
conditions: ConditionDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
type: "helper" | "other" | undefined
|
||||
): ListItem[] => {
|
||||
if (!conditions || !manifests) {
|
||||
return [];
|
||||
}
|
||||
const result: ListItem[] = [];
|
||||
const addedDomains = new Set<string>();
|
||||
Object.keys(conditions).forEach((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
|
||||
if (
|
||||
(type === undefined &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
|
||||
(type === "helper" && manifest?.integration_type === "helper") ||
|
||||
(type === "other" &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
!["helper", "entity"].includes(manifest?.integration_type || "")))
|
||||
) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon
|
||||
.hass=${this.hass}
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||
name: domainToName(localize, domain, manifest),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
return result.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
};
|
||||
|
||||
private _conditions = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
conditions: ConditionDescriptions,
|
||||
_manifests: DomainManifestLookup | undefined,
|
||||
group?: string
|
||||
): ListItem[] => {
|
||||
if (!conditions) {
|
||||
return [];
|
||||
}
|
||||
const result: ListItem[] = [];
|
||||
|
||||
for (const condition of Object.keys(conditions)) {
|
||||
const domain = getConditionDomain(condition);
|
||||
const conditionName = getConditionObjectId(condition);
|
||||
|
||||
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-condition-icon
|
||||
.hass=${this.hass}
|
||||
.condition=${condition}
|
||||
></ha-condition-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${condition}`,
|
||||
name:
|
||||
localize(`component.${domain}.conditions.${conditionName}.name`) ||
|
||||
condition,
|
||||
description:
|
||||
localize(
|
||||
`component.${domain}.conditions.${conditionName}.description`
|
||||
) || condition,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
private _services = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
@@ -832,6 +984,7 @@ class DialogAddAutomationElement
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._triggerDescriptions,
|
||||
this._conditionDescriptions,
|
||||
this._manifests
|
||||
);
|
||||
|
||||
@@ -1136,6 +1289,7 @@ class DialogAddAutomationElement
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._updateNarrow);
|
||||
this._removeSearchKeybindings();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
private _updateNarrow = () => {
|
||||
|
||||
@@ -8,11 +8,13 @@ import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { Condition } from "../../../../data/automation";
|
||||
import { expandConditionWithShorthand } from "../../../../data/automation";
|
||||
import type { ConditionDescription } from "../../../../data/condition";
|
||||
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import type { ConditionElement } from "./ha-automation-condition-row";
|
||||
import "./types/ha-automation-condition-platform";
|
||||
|
||||
@customElement("ha-automation-condition-editor")
|
||||
export default class HaAutomationConditionEditor extends LitElement {
|
||||
@@ -35,6 +37,8 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
@property({ type: Boolean, attribute: "supported" }) public uiSupported =
|
||||
false;
|
||||
|
||||
@property({ attribute: false }) public description?: ConditionDescription;
|
||||
|
||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||
|
||||
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
|
||||
@@ -83,16 +87,23 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
`
|
||||
: html`
|
||||
<div @value-changed=${this._onUiChanged}>
|
||||
${dynamicElement(
|
||||
`ha-automation-condition-${condition.condition}`,
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
disabled: this.disabled,
|
||||
optionsInSidebar: this.indent,
|
||||
narrow: this.narrow,
|
||||
}
|
||||
)}
|
||||
${this.description
|
||||
? html`<ha-automation-condition-platform
|
||||
.hass=${this.hass}
|
||||
.condition=${this.condition}
|
||||
.description=${this.description}
|
||||
.disabled=${this.disabled}
|
||||
></ha-automation-condition-platform>`
|
||||
: dynamicElement(
|
||||
`ha-automation-condition-${condition.condition}`,
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
disabled: this.disabled,
|
||||
optionsInSidebar: this.indent,
|
||||
narrow: this.narrow,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import "../../../../components/ha-automation-row";
|
||||
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-condition-icon";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-md-button-menu";
|
||||
@@ -44,10 +45,8 @@ import type {
|
||||
} from "../../../../data/automation";
|
||||
import { isCondition, testCondition } from "../../../../data/automation";
|
||||
import { describeCondition } from "../../../../data/automation_i18n";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
CONDITION_ICONS,
|
||||
} from "../../../../data/condition";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
||||
@@ -130,6 +129,9 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@property({ type: Boolean, attribute: "sidebar" })
|
||||
public optionsInSidebar = false;
|
||||
|
||||
@@ -179,11 +181,11 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
|
||||
private _renderRow() {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
<ha-condition-icon
|
||||
slot="leading-icon"
|
||||
class="condition-icon"
|
||||
.path=${CONDITION_ICONS[this.condition.condition]}
|
||||
></ha-svg-icon>
|
||||
.hass=${this.hass}
|
||||
.condition=${this.condition.condition}
|
||||
></ha-condition-icon>
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
@@ -395,9 +397,14 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
<ha-automation-condition-editor
|
||||
.hass=${this.hass}
|
||||
.condition=${this.condition}
|
||||
.description=${this.conditionDescriptions[
|
||||
this.condition.condition
|
||||
]}
|
||||
.disabled=${this.disabled}
|
||||
.yamlMode=${this._yamlMode}
|
||||
.uiSupported=${this._uiSupported(this.condition.condition)}
|
||||
.uiSupported=${this._uiSupported(
|
||||
this._getType(this.condition, this.conditionDescriptions)
|
||||
)}
|
||||
.narrow=${this.narrow}
|
||||
@ui-mode-not-available=${this._handleUiModeNotAvailable}
|
||||
></ha-automation-condition-editor>`
|
||||
@@ -476,7 +483,9 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.condition=${this.condition}
|
||||
.disabled=${this.disabled}
|
||||
.uiSupported=${this._uiSupported(this.condition.condition)}
|
||||
.uiSupported=${this._uiSupported(
|
||||
this._getType(this.condition, this.conditionDescriptions)
|
||||
)}
|
||||
indent
|
||||
.selected=${this._selected}
|
||||
.narrow=${this.narrow}
|
||||
@@ -786,7 +795,10 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
cut: this._cutCondition,
|
||||
test: this._testCondition,
|
||||
config: sidebarCondition,
|
||||
uiSupported: this._uiSupported(sidebarCondition.condition),
|
||||
uiSupported: this._uiSupported(
|
||||
this._getType(sidebarCondition, this.conditionDescriptions)
|
||||
),
|
||||
description: this.conditionDescriptions[sidebarCondition.condition],
|
||||
yamlMode: this._yamlMode,
|
||||
} satisfies ConditionSidebarConfig);
|
||||
this._selected = true;
|
||||
@@ -802,6 +814,16 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _getType = memoizeOne(
|
||||
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
|
||||
if (condition.condition in conditionDescriptions) {
|
||||
return "platform";
|
||||
}
|
||||
|
||||
return condition.condition;
|
||||
}
|
||||
);
|
||||
|
||||
private _uiSupported = memoizeOne(
|
||||
(type: string) =>
|
||||
customElements.get(`ha-automation-condition-${type}`) !== undefined
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
@@ -12,11 +13,18 @@ import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-button-menu";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
Condition,
|
||||
import {
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationClipboard,
|
||||
type Condition,
|
||||
} from "../../../../data/automation";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
subscribeConditions,
|
||||
} from "../../../../data/condition";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
@@ -25,10 +33,9 @@ import {
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import "./ha-automation-condition-row";
|
||||
import type HaAutomationConditionRow from "./ha-automation-condition-row";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
|
||||
@customElement("ha-automation-condition")
|
||||
export default class HaAutomationCondition extends LitElement {
|
||||
export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public conditions!: Condition[];
|
||||
@@ -46,6 +53,8 @@ export default class HaAutomationCondition extends LitElement {
|
||||
|
||||
@state() private _rowSortSelected?: number;
|
||||
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "automationClipboard",
|
||||
@@ -64,6 +73,26 @@ export default class HaAutomationCondition extends LitElement {
|
||||
|
||||
private _conditionKeys = new WeakMap<Condition, string>();
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeConditions(this.hass, (conditions) =>
|
||||
this._addConditions(conditions)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _addConditions(conditions: ConditionDescriptions) {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
if (!changedProperties.has("conditions")) {
|
||||
return;
|
||||
@@ -168,6 +197,7 @@ export default class HaAutomationCondition extends LitElement {
|
||||
.last=${idx === this.conditions.length - 1}
|
||||
.totalConditions=${this.conditions.length}
|
||||
.condition=${cond}
|
||||
.conditionDescriptions=${this._conditionDescriptions}
|
||||
.disabled=${this.disabled}
|
||||
.narrow=${this.narrow}
|
||||
@duplicate=${this._duplicateCondition}
|
||||
@@ -237,6 +267,10 @@ export default class HaAutomationCondition extends LitElement {
|
||||
conditions = this.conditions.concat(
|
||||
deepClone(this._clipboard!.condition)
|
||||
);
|
||||
} else if (isDynamic(value)) {
|
||||
conditions = this.conditions.concat({
|
||||
condition: getValueFromDynamic(value),
|
||||
});
|
||||
} else {
|
||||
const condition = value as Condition["condition"];
|
||||
const elClass = customElements.get(
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { mdiHelpCircle } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
type ConditionDescription,
|
||||
} from "../../../../../data/condition";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
|
||||
field.selector &&
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
@customElement("ha-automation-condition-platform")
|
||||
export class HaPlatformCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: PlatformCondition;
|
||||
|
||||
@property({ attribute: false }) public description?: ConditionDescription;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _checkedKeys = new Set();
|
||||
|
||||
@state() private _manifest?: IntegrationManifest;
|
||||
|
||||
public static get defaultConfig(): PlatformCondition {
|
||||
return { condition: "" };
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
if (!changedProperties.has("condition")) {
|
||||
return;
|
||||
}
|
||||
const oldValue = changedProperties.get("condition") as
|
||||
| undefined
|
||||
| this["condition"];
|
||||
|
||||
// Fetch the manifest if we have a condition selected and the condition domain changed.
|
||||
// If no condition is selected, clear the manifest.
|
||||
if (this.condition?.condition) {
|
||||
const domain = getConditionDomain(this.condition.condition);
|
||||
|
||||
const oldDomain = getConditionDomain(oldValue?.condition || "");
|
||||
|
||||
if (domain !== oldDomain) {
|
||||
this._fetchManifest(domain);
|
||||
}
|
||||
} else {
|
||||
this._manifest = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const domain = getConditionDomain(this.condition.condition);
|
||||
const conditionName = getConditionObjectId(this.condition.condition);
|
||||
|
||||
const description = this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.description`
|
||||
);
|
||||
|
||||
const conditionDesc = this.description;
|
||||
|
||||
const shouldRenderDataYaml = !conditionDesc?.fields;
|
||||
|
||||
const hasOptional = Boolean(
|
||||
conditionDesc?.fields &&
|
||||
Object.values(conditionDesc.fields).some((field) =>
|
||||
showOptionalToggle(field)
|
||||
)
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${this._manifest
|
||||
? html`<a
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiHelpCircle}
|
||||
class="help-icon"
|
||||
></ha-icon-button>
|
||||
</a>`
|
||||
: nothing}
|
||||
</div>
|
||||
${conditionDesc && "target" in conditionDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
${hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target_secondary"
|
||||
)}</span
|
||||
><ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.condition?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
: nothing}
|
||||
${shouldRenderDataYaml
|
||||
? html`<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.service-control.action_data"
|
||||
)}
|
||||
.name=${"data"}
|
||||
.readOnly=${this.disabled}
|
||||
.defaultValue=${this.condition?.options}
|
||||
@value-changed=${this._dataChanged}
|
||||
></ha-yaml-editor>`
|
||||
: Object.entries(conditionDesc.fields).map(([fieldName, dataField]) =>
|
||||
this._renderField(
|
||||
fieldName,
|
||||
dataField,
|
||||
hasOptional,
|
||||
domain,
|
||||
conditionName
|
||||
)
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _targetSelector = memoizeOne(
|
||||
(targetSelector: TargetSelector["target"] | null | undefined) =>
|
||||
targetSelector ? { target: { ...targetSelector } } : { target: {} }
|
||||
);
|
||||
|
||||
private _renderField = (
|
||||
fieldName: string,
|
||||
dataField: ConditionDescription["fields"][string],
|
||||
hasOptional: boolean,
|
||||
domain: string | undefined,
|
||||
conditionName: string | undefined
|
||||
) => {
|
||||
const selector = dataField?.selector ?? { text: null };
|
||||
|
||||
const showOptional = showOptionalToggle(dataField);
|
||||
|
||||
return dataField.selector
|
||||
? html`<ha-settings-row narrow>
|
||||
${!showOptional
|
||||
? hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing
|
||||
: html`<ha-checkbox
|
||||
.key=${fieldName}
|
||||
.checked=${this._checkedKeys.has(fieldName) ||
|
||||
(this.condition?.options &&
|
||||
this.condition.options[fieldName] !== undefined)}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
slot="prefix"
|
||||
></ha-checkbox>`}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
|
||||
) || conditionName}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.description`
|
||||
)}</span
|
||||
>
|
||||
<ha-selector
|
||||
.disabled=${this.disabled ||
|
||||
(showOptional &&
|
||||
!this._checkedKeys.has(fieldName) &&
|
||||
(!this.condition?.options ||
|
||||
this.condition.options[fieldName] === undefined))}
|
||||
.hass=${this.hass}
|
||||
.selector=${selector}
|
||||
.context=${this._generateContext(dataField)}
|
||||
.key=${fieldName}
|
||||
@value-changed=${this._dataChanged}
|
||||
.value=${this.condition?.options
|
||||
? this.condition.options[fieldName]
|
||||
: undefined}
|
||||
.placeholder=${dataField.default}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
></ha-selector>
|
||||
</ha-settings-row>`
|
||||
: nothing;
|
||||
};
|
||||
|
||||
private _generateContext(
|
||||
field: ConditionDescription["fields"][string]
|
||||
): Record<string, any> | undefined {
|
||||
if (!field.context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const context = {};
|
||||
for (const [context_key, data_key] of Object.entries(field.context)) {
|
||||
context[context_key] =
|
||||
data_key === "target"
|
||||
? this.condition.target
|
||||
: this.condition.options?.[data_key];
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private _dataChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (ev.detail.isValid === false) {
|
||||
// Don't clear an object selector that returns invalid YAML
|
||||
return;
|
||||
}
|
||||
const key = (ev.currentTarget as any).key;
|
||||
const value = ev.detail.value;
|
||||
if (
|
||||
this.condition?.options?.[key] === value ||
|
||||
((!this.condition?.options || !(key in this.condition.options)) &&
|
||||
(value === "" || value === undefined))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = { ...this.condition?.options, [key]: value };
|
||||
|
||||
if (
|
||||
value === "" ||
|
||||
value === undefined ||
|
||||
(typeof value === "object" && !Object.keys(value).length)
|
||||
) {
|
||||
delete options[key];
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _targetChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
target: ev.detail.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _checkboxChanged(ev) {
|
||||
const checked = ev.currentTarget.checked;
|
||||
const key = ev.currentTarget.key;
|
||||
let options;
|
||||
|
||||
if (checked) {
|
||||
this._checkedKeys.add(key);
|
||||
const field =
|
||||
this.description &&
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
options = {
|
||||
...this.condition?.options,
|
||||
[key]: defaultValue,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this._checkedKeys.delete(key);
|
||||
options = { ...this.condition?.options };
|
||||
delete options[key];
|
||||
}
|
||||
if (options) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.requestUpdate("_checkedKeys");
|
||||
}
|
||||
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
if (!this.condition?.condition) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${computeDomain(this.condition.condition)}.selector.${key}`
|
||||
);
|
||||
};
|
||||
|
||||
private async _fetchManifest(integration: string) {
|
||||
this._manifest = undefined;
|
||||
try {
|
||||
this._manifest = await fetchIntegrationManifest(this.hass, integration);
|
||||
} catch (_err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Unable to fetch integration manifest for ${integration}`);
|
||||
// Ignore if loading manifest fails. Probably bad JSON in manifest
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-settings-row {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-settings-row[narrow] {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-settings-row {
|
||||
--settings-row-content-width: 100%;
|
||||
--settings-row-prefix-display: contents;
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-service-picker,
|
||||
ha-entity-picker,
|
||||
ha-yaml-editor {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 var(--ha-space-4);
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
:host([hide-picker]) p {
|
||||
padding-top: 0;
|
||||
}
|
||||
.checkbox-spacer {
|
||||
width: 32px;
|
||||
}
|
||||
ha-checkbox {
|
||||
margin-left: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-start: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.help-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.description {
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 2px;
|
||||
padding-inline-end: 2px;
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.description p {
|
||||
direction: ltr;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-condition-platform": HaPlatformCondition;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -16,11 +16,16 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import {
|
||||
testCondition,
|
||||
type ConditionSidebarConfig,
|
||||
import type {
|
||||
LegacyCondition,
|
||||
ConditionSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { testCondition } from "../../../../data/automation";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
} from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
@@ -84,14 +89,25 @@ export default class HaAutomationSidebarCondition extends LitElement {
|
||||
"ui.panel.config.automation.editor.conditions.condition"
|
||||
);
|
||||
|
||||
const domain =
|
||||
"condition" in this.config.config &&
|
||||
getConditionDomain(this.config.config.condition);
|
||||
const conditionName =
|
||||
"condition" in this.config.config &&
|
||||
getConditionObjectId(this.config.config.condition);
|
||||
|
||||
const title =
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${type}.label`
|
||||
) || type;
|
||||
`ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.label`
|
||||
) ||
|
||||
this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.name`
|
||||
) ||
|
||||
type;
|
||||
|
||||
const description = isBuildingBlock
|
||||
? this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.${type}.description.picker`
|
||||
`ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.description.picker`
|
||||
)
|
||||
: "";
|
||||
|
||||
@@ -282,6 +298,7 @@ export default class HaAutomationSidebarCondition extends LitElement {
|
||||
class="sidebar-editor"
|
||||
.hass=${this.hass}
|
||||
.condition=${this.config.config}
|
||||
.description=${this.config.description}
|
||||
.yamlMode=${this.yamlMode}
|
||||
.uiSupported=${this.config.uiSupported}
|
||||
@value-changed=${this._valueChangedSidebar}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import {
|
||||
mdiDelete,
|
||||
mdiWater,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
DeviceConsumptionEnergyPreference,
|
||||
EnergyPreferences,
|
||||
EnergyPreferencesValidation,
|
||||
} from "../../../../data/energy";
|
||||
import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsDeviceWaterDialog } from "../dialogs/show-dialogs-energy";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
@customElement("ha-energy-device-settings-water")
|
||||
export class EnergyDeviceSettingsWater extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public preferences!: EnergyPreferences;
|
||||
|
||||
@property({ attribute: false })
|
||||
public statsMetadata?: Record<string, StatisticsMetaData>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<h1 class="card-header">
|
||||
<ha-svg-icon .path=${mdiWater}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.title"
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.sub"
|
||||
)}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/energy/water/#individual-devices"
|
||||
)}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.learn_more"
|
||||
)}</a
|
||||
>
|
||||
</p>
|
||||
${this.validationResult?.device_consumption_water.map(
|
||||
(result) => html`
|
||||
<ha-energy-validation-result
|
||||
.hass=${this.hass}
|
||||
.issues=${result}
|
||||
></ha-energy-validation-result>
|
||||
`
|
||||
)}
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.devices"
|
||||
)}
|
||||
</h3>
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._itemMoved}>
|
||||
<div class="devices">
|
||||
${repeat(
|
||||
this.preferences.device_consumption_water,
|
||||
(device) => device.stat_consumption,
|
||||
(device) => html`
|
||||
<div class="row" .device=${device}>
|
||||
<div class="handle">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<span class="content"
|
||||
>${device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this.statsMetadata?.[device.stat_consumption]
|
||||
)}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@click=${this._editDevice}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._deleteDevice}
|
||||
.device=${device}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="row">
|
||||
<ha-svg-icon .path=${mdiWater}></ha-svg-icon>
|
||||
<ha-button
|
||||
@click=${this._addDevice}
|
||||
appearance="filled"
|
||||
size="small"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.add_device"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemMoved(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const devices = this.preferences.device_consumption_water.concat();
|
||||
const device = devices.splice(oldIndex, 1)[0];
|
||||
devices.splice(newIndex, 0, device);
|
||||
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water: devices,
|
||||
};
|
||||
fireEvent(this, "value-changed", { value: newPrefs });
|
||||
this._savePreferences(newPrefs);
|
||||
}
|
||||
|
||||
private _editDevice(ev) {
|
||||
const origDevice: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.closest(".row").device;
|
||||
showEnergySettingsDeviceWaterDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: { ...origDevice },
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption_water as DeviceConsumptionEnergyPreference[],
|
||||
saveCallback: async (newDevice) => {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.map((d) =>
|
||||
d === origDevice ? newDevice : d
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
showEnergySettingsDeviceWaterDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption_water as DeviceConsumptionEnergyPreference[],
|
||||
saveCallback: async (device) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.concat(device),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _sanitizeParents(prefs: EnergyPreferences) {
|
||||
const statIds = prefs.device_consumption_water.map(
|
||||
(d) => d.stat_consumption
|
||||
);
|
||||
prefs.device_consumption_water.forEach((d) => {
|
||||
if (d.included_in_stat && !statIds.includes(d.included_in_stat)) {
|
||||
delete d.included_in_stat;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteDevice(ev) {
|
||||
const deviceToDelete: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.device;
|
||||
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption_water:
|
||||
this.preferences.device_consumption_water.filter(
|
||||
(device) => device !== deviceToDelete
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-energy-device-settings-water": EnergyDeviceSettingsWater;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { mdiWater } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-radio";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-list-item";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const volumeUnitClasses = ["volume"];
|
||||
|
||||
@customElement("dialog-energy-device-settings-water")
|
||||
export class DialogEnergyDeviceSettingsWater
|
||||
extends LitElement
|
||||
implements HassDialog<EnergySettingsDeviceWaterDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsDeviceWaterDialogParams;
|
||||
|
||||
@state() private _device?: DeviceConsumptionEnergyPreference;
|
||||
|
||||
@state() private _volume_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsDeviceWaterDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._device = this._params.device;
|
||||
this._computePossibleParents();
|
||||
this._volume_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
|
||||
).units;
|
||||
this._excludeList = this._params.device_consumptions
|
||||
.map((entry) => entry.stat_consumption)
|
||||
.filter((id) => id !== this._device?.stat_consumption);
|
||||
}
|
||||
|
||||
private _computePossibleParents() {
|
||||
if (!this._device || !this._params) {
|
||||
this._possibleParents = [];
|
||||
return;
|
||||
}
|
||||
const children: string[] = [];
|
||||
const devices = this._params.device_consumptions;
|
||||
function getChildren(stat) {
|
||||
devices.forEach((d) => {
|
||||
if (d.included_in_stat === stat) {
|
||||
children.push(d.stat_consumption);
|
||||
getChildren(d.stat_consumption);
|
||||
}
|
||||
});
|
||||
}
|
||||
getChildren(this._device.stat_consumption);
|
||||
this._possibleParents = this._params.device_consumptions.filter(
|
||||
(d) =>
|
||||
d.stat_consumption !== this._device!.stat_consumption &&
|
||||
d.stat_consumption !== this._params?.device?.stat_consumption &&
|
||||
!children.includes(d.stat_consumption)
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._params = undefined;
|
||||
this._device = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
return true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._volume_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
.heading=${html`<ha-svg-icon
|
||||
.path=${mdiWater}
|
||||
style="--mdc-icon-size: 32px;"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.header"
|
||||
)}`}
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.includeUnitClass=${volumeUnitClasses}
|
||||
.value=${this._device?.stat_consumption}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.device_consumption_water"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-textfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.display_name"
|
||||
)}
|
||||
type="text"
|
||||
.disabled=${!this._device}
|
||||
.value=${this._device?.name || ""}
|
||||
.placeholder=${this._device
|
||||
? getStatisticLabel(
|
||||
this.hass,
|
||||
this._device.stat_consumption,
|
||||
this._params?.statsMetadata?.[this._device.stat_consumption]
|
||||
)
|
||||
: ""}
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
</ha-textfield>
|
||||
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device"
|
||||
)}
|
||||
.value=${this._device?.included_in_stat || ""}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device_helper"
|
||||
)}
|
||||
.disabled=${!this._device}
|
||||
@selected=${this._parentSelected}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
clearable
|
||||
>
|
||||
${!this._possibleParents.length
|
||||
? html`
|
||||
<ha-list-item disabled value="-"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption_water.dialog.no_upstream_devices"
|
||||
)}</ha-list-item
|
||||
>
|
||||
`
|
||||
: this._possibleParents.map(
|
||||
(stat) => html`
|
||||
<ha-list-item .value=${stat.stat_consumption}
|
||||
>${stat.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
stat.stat_consumption,
|
||||
this._params?.statsMetadata?.[stat.stat_consumption]
|
||||
)}</ha-list-item
|
||||
>
|
||||
`
|
||||
)}
|
||||
</ha-select>
|
||||
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${!this._device}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _statisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||
if (!ev.detail.value) {
|
||||
this._device = undefined;
|
||||
return;
|
||||
}
|
||||
this._device = { stat_consumption: ev.detail.value };
|
||||
this._computePossibleParents();
|
||||
}
|
||||
|
||||
private _nameChanged(ev) {
|
||||
const newDevice = {
|
||||
...this._device!,
|
||||
name: ev.target!.value,
|
||||
} as DeviceConsumptionEnergyPreference;
|
||||
if (!newDevice.name) {
|
||||
delete newDevice.name;
|
||||
}
|
||||
this._device = newDevice;
|
||||
}
|
||||
|
||||
private _parentSelected(ev) {
|
||||
const newDevice = {
|
||||
...this._device!,
|
||||
included_in_stat: ev.target!.value,
|
||||
} as DeviceConsumptionEnergyPreference;
|
||||
if (!newDevice.included_in_stat) {
|
||||
delete newDevice.included_in_stat;
|
||||
}
|
||||
this._device = newDevice;
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
await this._params!.saveCallback(this._device!);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
ha-select {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
ha-textfield {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-device-settings-water": DialogEnergyDeviceSettingsWater;
|
||||
}
|
||||
}
|
||||
@@ -83,13 +83,6 @@ export interface EnergySettingsDeviceDialogParams {
|
||||
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EnergySettingsDeviceWaterDialogParams {
|
||||
device?: DeviceConsumptionEnergyPreference;
|
||||
device_consumptions: DeviceConsumptionEnergyPreference[];
|
||||
statsMetadata?: Record<string, StatisticsMetaData>;
|
||||
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
export const showEnergySettingsDeviceDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: EnergySettingsDeviceDialogParams
|
||||
@@ -167,17 +160,6 @@ export const showEnergySettingsGridFlowToDialog = (
|
||||
});
|
||||
};
|
||||
|
||||
export const showEnergySettingsDeviceWaterDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: EnergySettingsDeviceWaterDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-energy-device-settings-water",
|
||||
dialogImport: () => import("./dialog-energy-device-settings-water"),
|
||||
dialogParams: dialogParams,
|
||||
});
|
||||
};
|
||||
|
||||
export const showEnergySettingsGridPowerDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: EnergySettingsGridPowerDialogParams
|
||||
|
||||
@@ -22,7 +22,6 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "../../../components/ha-alert";
|
||||
import "./components/ha-energy-device-settings";
|
||||
import "./components/ha-energy-device-settings-water";
|
||||
import "./components/ha-energy-grid-settings";
|
||||
import "./components/ha-energy-solar-settings";
|
||||
import "./components/ha-energy-battery-settings";
|
||||
@@ -33,7 +32,6 @@ import { fileDownload } from "../../../util/file_download";
|
||||
const INITIAL_CONFIG: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
};
|
||||
|
||||
@customElement("ha-config-energy")
|
||||
@@ -144,13 +142,6 @@ class HaConfigEnergy extends LitElement {
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings>
|
||||
<ha-energy-device-settings-water
|
||||
.hass=${this.hass}
|
||||
.preferences=${this._preferences!}
|
||||
.statsMetadata=${this._statsMetadata}
|
||||
.validationResult=${this._validationResult}
|
||||
@value-changed=${this._prefsChanged}
|
||||
></ha-energy-device-settings-water>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mdiCancel,
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiMenuDown,
|
||||
mdiPencilOff,
|
||||
@@ -109,10 +110,11 @@ import { configSections } from "../ha-panel-config";
|
||||
import "../integrations/ha-integration-overflow-menu";
|
||||
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import { isHelperDomain } from "./const";
|
||||
import { isHelperDomain, type HelperDomain } from "./const";
|
||||
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { HELPERS_CRUD } from "../../../data/helpers_crud";
|
||||
import {
|
||||
fetchDiagnosticHandlers,
|
||||
getConfigEntryDiagnosticsDownloadUrl,
|
||||
@@ -451,6 +453,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(helper.editable && helper.entity
|
||||
? [
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
path: mdiDelete,
|
||||
label: this.hass.localize("ui.common.delete"),
|
||||
warning: true,
|
||||
action: () => this._deleteHelper(helper),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
</ha-icon-overflow-menu>
|
||||
@@ -1280,6 +1295,62 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteHelper(helper: HelperItem) {
|
||||
if (!helper.entity_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.helpers.picker.delete_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.helpers.picker.delete_confirm_text",
|
||||
{ name: helper.name }
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For old-style helpers (input_boolean, etc.), use HELPERS_CRUD
|
||||
if (isHelperDomain(helper.type)) {
|
||||
const entityReg = this._entityReg.find(
|
||||
(e) => e.entity_id === helper.entity_id
|
||||
);
|
||||
if (
|
||||
!entityReg?.unique_id ||
|
||||
!isComponentLoaded(this.hass, helper.type)
|
||||
) {
|
||||
throw new Error(
|
||||
this.hass.localize("ui.panel.config.helpers.picker.delete_failed")
|
||||
);
|
||||
}
|
||||
await HELPERS_CRUD[helper.type as HelperDomain].delete(
|
||||
this.hass,
|
||||
entityReg.unique_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For config entry-based helpers, delete the config entry
|
||||
if (helper.configEntry) {
|
||||
await deleteConfigEntry(this.hass, helper.configEntry.entry_id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text:
|
||||
err.message ||
|
||||
this.hass.localize("ui.panel.config.helpers.picker.delete_failed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _createHelper() {
|
||||
showHelperDetailDialog(this, {});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
223
src/panels/config/labs/dialog-labs-preview-feature-enable.ts
Normal file
223
src/panels/config/labs/dialog-labs-preview-feature-enable.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
src/panels/config/labs/dialog-labs-progress.ts
Normal file
113
src/panels/config/labs/dialog-labs-progress.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
550
src/panels/config/labs/ha-config-labs.ts
Normal file
550
src/panels/config/labs/ha-config-labs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
22
src/panels/config/labs/show-dialog-labs-progress.ts
Normal file
22
src/panels/config/labs/show-dialog-labs-progress.ts
Normal 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");
|
||||
@@ -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() {
|
||||
@@ -283,7 +309,20 @@ export class DialogLovelaceDashboardDetail extends LitElement {
|
||||
}
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._error = { base: err?.message || "Unknown error" };
|
||||
let localizedErrorMessage: string | undefined;
|
||||
if (err?.translation_domain && err?.translation_key) {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"exceptions",
|
||||
err.translation_domain
|
||||
);
|
||||
localizedErrorMessage = localize(
|
||||
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
|
||||
err.translation_placeholders
|
||||
);
|
||||
}
|
||||
this._error = {
|
||||
base: localizedErrorMessage || err?.message || "Unknown error",
|
||||
};
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,7 +30,6 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
|
||||
@state() private _preferences: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
};
|
||||
|
||||
public getCardSize() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
218
src/panels/energy/strategies/energy-overview-view-strategy.ts
Normal file
218
src/panels/energy/strategies/energy-overview-view-strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
151
src/panels/home/dialogs/dialog-edit-home.ts
Normal file
151
src/panels/home/dialogs/dialog-edit-home.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/panels/home/dialogs/show-dialog-edit-home.ts
Normal file
20
src/panels/home/dialogs/show-dialog-edit-home.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user