mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-14 13:31:10 +00:00
Compare commits
39 Commits
copilot/fi
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df40edb7a | ||
|
|
332694549c | ||
|
|
396ddef722 | ||
|
|
d02804449a | ||
|
|
4ab24cdc72 | ||
|
|
81c27090d2 | ||
|
|
09bdfd3ad7 | ||
|
|
97e49f751c | ||
|
|
e0d241a2db | ||
|
|
83e065ae98 | ||
|
|
a6ee670682 | ||
|
|
c457f92826 | ||
|
|
c73bd96a1f | ||
|
|
711f8e2fc3 | ||
|
|
91a0066544 | ||
|
|
aee7b8b8d4 | ||
|
|
d38d770e1a | ||
|
|
0036679553 | ||
|
|
2b85108242 | ||
|
|
c74320cb82 | ||
|
|
8ebe6e24d2 | ||
|
|
d3182da587 | ||
|
|
41fbc5e44b | ||
|
|
3fea41eb0e | ||
|
|
9a627bdea7 | ||
|
|
d9a67f603d | ||
|
|
5f37f8c0ab | ||
|
|
f222702abf | ||
|
|
2107b7c267 | ||
|
|
1c05afebd7 | ||
|
|
7179bb2d26 | ||
|
|
95cf1fdcf7 | ||
|
|
9617956cc6 | ||
|
|
65df464731 | ||
|
|
bd4e9a3d05 | ||
|
|
963fc13a99 | ||
|
|
ff614918d4 | ||
|
|
48aa5fb970 | ||
|
|
190af65756 |
@@ -260,7 +260,6 @@ const createRspackConfig = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
experiments: {
|
experiments: {
|
||||||
layers: true,
|
|
||||||
outputModule: true,
|
outputModule: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
|||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import "../../../../src/components/ha-card";
|
import "../../../../src/components/ha-card";
|
||||||
import "../../../../src/components/ha-yaml-editor";
|
import "../../../../src/components/ha-yaml-editor";
|
||||||
import type { Trigger } from "../../../../src/data/automation";
|
import type { LegacyTrigger } from "../../../../src/data/automation";
|
||||||
import { describeTrigger } from "../../../../src/data/automation_i18n";
|
import { describeTrigger } from "../../../../src/data/automation_i18n";
|
||||||
import { getEntity } from "../../../../src/fake_data/entity";
|
import { getEntity } from "../../../../src/fake_data/entity";
|
||||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||||
@@ -66,7 +66,7 @@ const triggers = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const initialTrigger: Trigger = {
|
const initialTrigger: LegacyTrigger = {
|
||||||
trigger: "state",
|
trigger: "state",
|
||||||
entity_id: "light.kitchen",
|
entity_id: "light.kitchen",
|
||||||
};
|
};
|
||||||
|
|||||||
55
gallery/src/pages/components/ha-dropdown.markdown
Normal file
55
gallery/src/pages/components/ha-dropdown.markdown
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: Dropdown
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dropdown `<ha-dropdown>`
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
|
||||||
|
|
||||||
|
### Example usage (composition)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ha-dropdown open>
|
||||||
|
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
|
||||||
|
|
||||||
|
<ha-dropdown-item>
|
||||||
|
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
|
||||||
|
Cut
|
||||||
|
</ha-dropdown-item>
|
||||||
|
|
||||||
|
<ha-dropdown-item>
|
||||||
|
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
|
||||||
|
Copy
|
||||||
|
</ha-dropdown-item>
|
||||||
|
|
||||||
|
<ha-dropdown-item disabled>
|
||||||
|
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
|
||||||
|
Paste
|
||||||
|
</ha-dropdown-item>
|
||||||
|
|
||||||
|
<ha-dropdown-item>
|
||||||
|
Show images
|
||||||
|
<ha-dropdown-item slot="submenu" value="show-all-images"
|
||||||
|
>Show all images</ha-dropdown-item
|
||||||
|
>
|
||||||
|
<ha-dropdown-item slot="submenu" value="show-thumbnails"
|
||||||
|
>Show thumbnails</ha-dropdown-item
|
||||||
|
>
|
||||||
|
</ha-dropdown-item>
|
||||||
|
|
||||||
|
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
|
||||||
|
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
|
||||||
|
|
||||||
|
<ha-dropdown-item variant="danger">
|
||||||
|
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
|
||||||
|
Delete
|
||||||
|
</ha-dropdown-item>
|
||||||
|
</ha-dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
This component is based on the webawesome dropdown component.
|
||||||
|
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.
|
||||||
133
gallery/src/pages/components/ha-dropdown.ts
Normal file
133
gallery/src/pages/components/ha-dropdown.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import "@home-assistant/webawesome/dist/components/button/button";
|
||||||
|
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
|
||||||
|
import "@home-assistant/webawesome/dist/components/icon/icon";
|
||||||
|
import "@home-assistant/webawesome/dist/components/popup/popup";
|
||||||
|
import {
|
||||||
|
mdiContentCopy,
|
||||||
|
mdiContentCut,
|
||||||
|
mdiContentPaste,
|
||||||
|
mdiDelete,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import type { TemplateResult } from "lit";
|
||||||
|
import { css, html, LitElement } from "lit";
|
||||||
|
import { customElement } from "lit/decorators";
|
||||||
|
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||||
|
import "../../../../src/components/ha-button";
|
||||||
|
import "../../../../src/components/ha-card";
|
||||||
|
import "../../../../src/components/ha-dropdown";
|
||||||
|
import "../../../../src/components/ha-dropdown-item";
|
||||||
|
import "../../../../src/components/ha-icon-button";
|
||||||
|
import "../../../../src/components/ha-svg-icon";
|
||||||
|
|
||||||
|
@customElement("demo-components-ha-dropdown")
|
||||||
|
export class DemoHaDropdown extends LitElement {
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
${["light", "dark"].map(
|
||||||
|
(mode) => html`
|
||||||
|
<div class=${mode}>
|
||||||
|
<ha-card header="ha-button in ${mode}">
|
||||||
|
<div class="card-content">
|
||||||
|
<ha-dropdown open>
|
||||||
|
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
|
||||||
|
|
||||||
|
<ha-dropdown-item>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiContentCut}
|
||||||
|
slot="icon"
|
||||||
|
></ha-svg-icon>
|
||||||
|
Cut
|
||||||
|
</ha-dropdown-item>
|
||||||
|
<ha-dropdown-item>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiContentCopy}
|
||||||
|
slot="icon"
|
||||||
|
></ha-svg-icon>
|
||||||
|
Copy
|
||||||
|
</ha-dropdown-item>
|
||||||
|
<ha-dropdown-item disabled>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiContentPaste}
|
||||||
|
slot="icon"
|
||||||
|
></ha-svg-icon>
|
||||||
|
Paste
|
||||||
|
</ha-dropdown-item>
|
||||||
|
<ha-dropdown-item>
|
||||||
|
Show images
|
||||||
|
<ha-dropdown-item slot="submenu" value="show-all-images"
|
||||||
|
>Show All Images</ha-dropdown-item
|
||||||
|
>
|
||||||
|
<ha-dropdown-item slot="submenu" value="show-thumbnails"
|
||||||
|
>Show Thumbnails</ha-dropdown-item
|
||||||
|
>
|
||||||
|
</ha-dropdown-item>
|
||||||
|
<ha-dropdown-item type="checkbox" checked
|
||||||
|
>Emoji Shortcuts</ha-dropdown-item
|
||||||
|
>
|
||||||
|
<ha-dropdown-item type="checkbox" checked
|
||||||
|
>Word Wrap</ha-dropdown-item
|
||||||
|
>
|
||||||
|
<ha-dropdown-item variant="danger">
|
||||||
|
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
|
||||||
|
Delete
|
||||||
|
</ha-dropdown-item>
|
||||||
|
</ha-dropdown>
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated(changedProps) {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
applyThemesOnElement(
|
||||||
|
this.shadowRoot!.querySelector(".dark"),
|
||||||
|
{
|
||||||
|
default_theme: "default",
|
||||||
|
default_dark_theme: "default",
|
||||||
|
themes: {},
|
||||||
|
darkMode: true,
|
||||||
|
theme: "default",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dark,
|
||||||
|
.light {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--primary-background-color);
|
||||||
|
padding: 0 50px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
padding: unset;
|
||||||
|
}
|
||||||
|
ha-card {
|
||||||
|
margin: 24px auto;
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.card-content div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"demo-components-ha-dropdown": DemoHaDropdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package.json
14
package.json
@@ -52,7 +52,7 @@
|
|||||||
"@fullcalendar/list": "6.1.19",
|
"@fullcalendar/list": "6.1.19",
|
||||||
"@fullcalendar/luxon3": "6.1.19",
|
"@fullcalendar/luxon3": "6.1.19",
|
||||||
"@fullcalendar/timegrid": "6.1.19",
|
"@fullcalendar/timegrid": "6.1.19",
|
||||||
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
|
"@home-assistant/webawesome": "3.0.0",
|
||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
"@lit-labs/motion": "1.0.9",
|
"@lit-labs/motion": "1.0.9",
|
||||||
"@lit-labs/observers": "2.0.6",
|
"@lit-labs/observers": "2.0.6",
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
"@thomasloven/round-slider": "0.6.0",
|
"@thomasloven/round-slider": "0.6.0",
|
||||||
"@tsparticles/engine": "3.9.1",
|
"@tsparticles/engine": "3.9.1",
|
||||||
"@tsparticles/preset-links": "3.2.0",
|
"@tsparticles/preset-links": "3.2.0",
|
||||||
"@vaadin/combo-box": "24.9.4",
|
"@vaadin/combo-box": "24.9.5",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.9.4",
|
"@vaadin/vaadin-themable-mixin": "24.9.5",
|
||||||
"@vibrant/color": "4.0.0",
|
"@vibrant/color": "4.0.0",
|
||||||
"@vue/web-component-wrapper": "1.3.0",
|
"@vue/web-component-wrapper": "1.3.0",
|
||||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
"lit": "3.3.1",
|
"lit": "3.3.1",
|
||||||
"lit-html": "3.3.1",
|
"lit-html": "3.3.1",
|
||||||
"luxon": "3.7.2",
|
"luxon": "3.7.2",
|
||||||
"marked": "16.4.2",
|
"marked": "17.0.0",
|
||||||
"memoize-one": "6.0.0",
|
"memoize-one": "6.0.0",
|
||||||
"node-vibrant": "4.0.3",
|
"node-vibrant": "4.0.3",
|
||||||
"object-hash": "3.0.0",
|
"object-hash": "3.0.0",
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
"@types/tar": "6.1.13",
|
"@types/tar": "6.1.13",
|
||||||
"@types/ua-parser-js": "0.7.39",
|
"@types/ua-parser-js": "0.7.39",
|
||||||
"@types/webspeechapi": "0.0.29",
|
"@types/webspeechapi": "0.0.29",
|
||||||
"@vitest/coverage-v8": "4.0.7",
|
"@vitest/coverage-v8": "4.0.8",
|
||||||
"babel-loader": "10.0.0",
|
"babel-loader": "10.0.0",
|
||||||
"babel-plugin-template-html-minifier": "4.1.0",
|
"babel-plugin-template-html-minifier": "4.1.0",
|
||||||
"browserslist-useragent-regexp": "4.1.3",
|
"browserslist-useragent-regexp": "4.1.3",
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.46.3",
|
"typescript-eslint": "8.46.3",
|
||||||
"vite-tsconfig-paths": "5.1.4",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "4.0.7",
|
"vitest": "4.0.8",
|
||||||
"webpack-stats-plugin": "1.1.3",
|
"webpack-stats-plugin": "1.1.3",
|
||||||
"webpackbar": "7.0.0",
|
"webpackbar": "7.0.0",
|
||||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||||
@@ -237,6 +237,6 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "yarn@4.10.3",
|
"packageManager": "yarn@4.10.3",
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "22.21.1"
|
"node": "24.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/common/condition/extract.ts
Normal file
36
src/common/condition/extract.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type {
|
||||||
|
Condition,
|
||||||
|
TimeCondition,
|
||||||
|
} from "../../panels/lovelace/common/validate-condition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract media queries from conditions recursively
|
||||||
|
*/
|
||||||
|
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||||
|
return conditions.reduce<string[]>((array, c) => {
|
||||||
|
if ("conditions" in c && c.conditions) {
|
||||||
|
array.push(...extractMediaQueries(c.conditions));
|
||||||
|
}
|
||||||
|
if (c.condition === "screen" && c.media_query) {
|
||||||
|
array.push(c.media_query);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract time conditions from conditions recursively
|
||||||
|
*/
|
||||||
|
export function extractTimeConditions(
|
||||||
|
conditions: Condition[]
|
||||||
|
): TimeCondition[] {
|
||||||
|
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||||
|
if ("conditions" in c && c.conditions) {
|
||||||
|
array.push(...extractTimeConditions(c.conditions));
|
||||||
|
}
|
||||||
|
if (c.condition === "time") {
|
||||||
|
array.push(c);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
89
src/common/condition/listeners.ts
Normal file
89
src/common/condition/listeners.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { listenMediaQuery } from "../dom/media_query";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import type { Condition } from "../../panels/lovelace/common/validate-condition";
|
||||||
|
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||||
|
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||||
|
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||||
|
|
||||||
|
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
|
||||||
|
* Values exceeding this will overflow and execute immediately
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
|
||||||
|
*/
|
||||||
|
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to setup media query listeners for conditional visibility
|
||||||
|
*/
|
||||||
|
export function setupMediaQueryListeners(
|
||||||
|
conditions: Condition[],
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addListener: (unsub: () => void) => void,
|
||||||
|
onUpdate: (conditionsMet: boolean) => void
|
||||||
|
): void {
|
||||||
|
const mediaQueries = extractMediaQueries(conditions);
|
||||||
|
|
||||||
|
if (mediaQueries.length === 0) return;
|
||||||
|
|
||||||
|
// Optimization for single media query
|
||||||
|
const hasOnlyMediaQuery =
|
||||||
|
conditions.length === 1 &&
|
||||||
|
conditions[0].condition === "screen" &&
|
||||||
|
!!conditions[0].media_query;
|
||||||
|
|
||||||
|
mediaQueries.forEach((mediaQuery) => {
|
||||||
|
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||||
|
if (hasOnlyMediaQuery) {
|
||||||
|
onUpdate(matches);
|
||||||
|
} else {
|
||||||
|
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||||
|
onUpdate(conditionsMet);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addListener(unsub);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to setup time-based listeners for conditional visibility
|
||||||
|
*/
|
||||||
|
export function setupTimeListeners(
|
||||||
|
conditions: Condition[],
|
||||||
|
hass: HomeAssistant,
|
||||||
|
addListener: (unsub: () => void) => void,
|
||||||
|
onUpdate: (conditionsMet: boolean) => void
|
||||||
|
): void {
|
||||||
|
const timeConditions = extractTimeConditions(conditions);
|
||||||
|
|
||||||
|
if (timeConditions.length === 0) return;
|
||||||
|
|
||||||
|
timeConditions.forEach((timeCondition) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const scheduleUpdate = () => {
|
||||||
|
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||||
|
|
||||||
|
if (delay === undefined) return;
|
||||||
|
|
||||||
|
// Cap delay to prevent setTimeout overflow
|
||||||
|
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||||
|
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||||
|
onUpdate(conditionsMet);
|
||||||
|
}
|
||||||
|
scheduleUpdate();
|
||||||
|
}, cappedDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register cleanup function once, outside of scheduleUpdate
|
||||||
|
addListener(() => {
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
73
src/common/condition/time-calculator.ts
Normal file
73
src/common/condition/time-calculator.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { TZDate } from "@date-fns/tz";
|
||||||
|
import {
|
||||||
|
startOfDay,
|
||||||
|
addDays,
|
||||||
|
addMinutes,
|
||||||
|
differenceInMilliseconds,
|
||||||
|
} from "date-fns";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import { TimeZone } from "../../data/translation";
|
||||||
|
import { parseTimeString } from "../datetime/check_time";
|
||||||
|
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate milliseconds until next time boundary for a time condition
|
||||||
|
* @param hass Home Assistant object
|
||||||
|
* @param timeCondition Time condition to calculate next update for
|
||||||
|
* @returns Milliseconds until next boundary, or undefined if no boundaries
|
||||||
|
*/
|
||||||
|
export function calculateNextTimeUpdate(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
{ after, before, weekdays }: Omit<TimeCondition, "condition">
|
||||||
|
): number | undefined {
|
||||||
|
const timezone =
|
||||||
|
hass.locale.time_zone === TimeZone.server
|
||||||
|
? hass.config.time_zone
|
||||||
|
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
const now = new TZDate(new Date(), timezone);
|
||||||
|
const updates: Date[] = [];
|
||||||
|
|
||||||
|
// Calculate next occurrence of after time
|
||||||
|
if (after) {
|
||||||
|
let afterDate = parseTimeString(after, timezone);
|
||||||
|
if (afterDate <= now) {
|
||||||
|
// If time has passed today, schedule for tomorrow
|
||||||
|
afterDate = addDays(afterDate, 1);
|
||||||
|
}
|
||||||
|
updates.push(afterDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next occurrence of before time
|
||||||
|
if (before) {
|
||||||
|
let beforeDate = parseTimeString(before, timezone);
|
||||||
|
if (beforeDate <= now) {
|
||||||
|
// If time has passed today, schedule for tomorrow
|
||||||
|
beforeDate = addDays(beforeDate, 1);
|
||||||
|
}
|
||||||
|
updates.push(beforeDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If weekdays are specified, check for midnight (weekday transition)
|
||||||
|
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
|
||||||
|
// Calculate next midnight using startOfDay + addDays
|
||||||
|
const tomorrow = addDays(now, 1);
|
||||||
|
const midnight = startOfDay(tomorrow);
|
||||||
|
updates.push(midnight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the soonest update time
|
||||||
|
const nextUpdate = updates.reduce((soonest, current) =>
|
||||||
|
current < soonest ? current : soonest
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add 1 minute buffer to ensure we're past the boundary
|
||||||
|
const updateWithBuffer = addMinutes(nextUpdate, 1);
|
||||||
|
|
||||||
|
// Calculate difference in milliseconds
|
||||||
|
return differenceInMilliseconds(updateWithBuffer, now);
|
||||||
|
}
|
||||||
131
src/common/datetime/check_time.ts
Normal file
131
src/common/datetime/check_time.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { TZDate } from "@date-fns/tz";
|
||||||
|
import { isBefore, isAfter, isWithinInterval } from "date-fns";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import { TimeZone } from "../../data/translation";
|
||||||
|
import { WEEKDAY_MAP } from "./weekday";
|
||||||
|
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a time string format and value ranges without creating Date objects
|
||||||
|
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidTimeString(timeString: string): boolean {
|
||||||
|
// Reject empty strings
|
||||||
|
if (!timeString || timeString.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = timeString.split(":");
|
||||||
|
|
||||||
|
if (parts.length < 2 || parts.length > 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure each part contains only digits (and optional leading zeros)
|
||||||
|
// This prevents "8:00 AM" from passing validation
|
||||||
|
if (!parts.every((part) => /^\d+$/.test(part))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
|
||||||
|
|
||||||
|
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
hours >= 0 &&
|
||||||
|
hours <= 23 &&
|
||||||
|
minutes >= 0 &&
|
||||||
|
minutes <= 59 &&
|
||||||
|
seconds >= 0 &&
|
||||||
|
seconds <= 59
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
|
||||||
|
*
|
||||||
|
* Note: This function assumes the time string has already been validated by
|
||||||
|
* isValidTimeString() at configuration time. It does not re-validate at runtime
|
||||||
|
* for consistency with other condition types (screen, user, location, etc.)
|
||||||
|
*
|
||||||
|
* @param timeString The time string to parse (must be pre-validated)
|
||||||
|
* @param timezone The timezone to use
|
||||||
|
* @returns The Date object
|
||||||
|
*/
|
||||||
|
export const parseTimeString = (timeString: string, timezone: string): Date => {
|
||||||
|
const parts = timeString.split(":");
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
|
||||||
|
|
||||||
|
const now = new TZDate(new Date(), timezone);
|
||||||
|
const dateWithTime = new TZDate(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
0,
|
||||||
|
timezone
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Date(dateWithTime.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current time matches the time condition (after/before/weekday)
|
||||||
|
* @param hass Home Assistant object
|
||||||
|
* @param timeCondition Time condition to check
|
||||||
|
* @returns true if current time matches the condition
|
||||||
|
*/
|
||||||
|
export const checkTimeInRange = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
{ after, before, weekdays }: Omit<TimeCondition, "condition">
|
||||||
|
): boolean => {
|
||||||
|
const timezone =
|
||||||
|
hass.locale.time_zone === TimeZone.server
|
||||||
|
? hass.config.time_zone
|
||||||
|
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
const now = new TZDate(new Date(), timezone);
|
||||||
|
|
||||||
|
// Check weekday condition
|
||||||
|
if (weekdays && weekdays.length > 0) {
|
||||||
|
const currentWeekday = WEEKDAY_MAP[now.getDay()];
|
||||||
|
if (!weekdays.includes(currentWeekday)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check time conditions
|
||||||
|
if (!after && !before) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterDate = after ? parseTimeString(after, timezone) : undefined;
|
||||||
|
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
|
||||||
|
|
||||||
|
if (afterDate && beforeDate) {
|
||||||
|
if (isBefore(beforeDate, afterDate)) {
|
||||||
|
// Crosses midnight (e.g., 22:00 to 06:00)
|
||||||
|
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
|
||||||
|
}
|
||||||
|
return isWithinInterval(now, { start: afterDate, end: beforeDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (afterDate) {
|
||||||
|
return !isBefore(now, afterDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeDate) {
|
||||||
|
return !isAfter(now, beforeDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
import { getWeekStartByLocale } from "weekstart";
|
import { getWeekStartByLocale } from "weekstart";
|
||||||
import type { FrontendLocaleData } from "../../data/translation";
|
import type { FrontendLocaleData } from "../../data/translation";
|
||||||
import { FirstWeekday } from "../../data/translation";
|
import { FirstWeekday } from "../../data/translation";
|
||||||
|
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
|
||||||
export const weekdays = [
|
|
||||||
"sunday",
|
|
||||||
"monday",
|
|
||||||
"tuesday",
|
|
||||||
"wednesday",
|
|
||||||
"thursday",
|
|
||||||
"friday",
|
|
||||||
"saturday",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
||||||
|
|
||||||
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||||
if (locale.first_weekday === FirstWeekday.language) {
|
if (locale.first_weekday === FirstWeekday.language) {
|
||||||
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
|||||||
}
|
}
|
||||||
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
|
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
|
||||||
}
|
}
|
||||||
return weekdays.includes(locale.first_weekday)
|
return WEEKDAYS_LONG.includes(locale.first_weekday)
|
||||||
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
|
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||||
: 1;
|
: 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const firstWeekday = (locale: FrontendLocaleData) => {
|
export const firstWeekday = (locale: FrontendLocaleData) => {
|
||||||
const index = firstWeekdayIndex(locale);
|
const index = firstWeekdayIndex(locale);
|
||||||
return weekdays[index];
|
return WEEKDAYS_LONG[index];
|
||||||
};
|
};
|
||||||
|
|||||||
59
src/common/datetime/weekday.ts
Normal file
59
src/common/datetime/weekday.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
|
||||||
|
export type WeekdayShort =
|
||||||
|
| "sun"
|
||||||
|
| "mon"
|
||||||
|
| "tue"
|
||||||
|
| "wed"
|
||||||
|
| "thu"
|
||||||
|
| "fri"
|
||||||
|
| "sat";
|
||||||
|
|
||||||
|
export type WeekdayLong =
|
||||||
|
| "sunday"
|
||||||
|
| "monday"
|
||||||
|
| "tuesday"
|
||||||
|
| "wednesday"
|
||||||
|
| "thursday"
|
||||||
|
| "friday"
|
||||||
|
| "saturday";
|
||||||
|
|
||||||
|
export const WEEKDAYS_SHORT = [
|
||||||
|
"sun",
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
] as const satisfies readonly WeekdayShort[];
|
||||||
|
|
||||||
|
export const WEEKDAYS_LONG = [
|
||||||
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
] as const satisfies readonly WeekdayLong[];
|
||||||
|
|
||||||
|
export const WEEKDAY_MAP = {
|
||||||
|
0: "sun",
|
||||||
|
1: "mon",
|
||||||
|
2: "tue",
|
||||||
|
3: "wed",
|
||||||
|
4: "thu",
|
||||||
|
5: "fri",
|
||||||
|
6: "sat",
|
||||||
|
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
|
||||||
|
|
||||||
|
export const WEEKDAY_SHORT_TO_LONG = {
|
||||||
|
sun: "sunday",
|
||||||
|
mon: "monday",
|
||||||
|
tue: "tuesday",
|
||||||
|
wed: "wednesday",
|
||||||
|
thu: "thursday",
|
||||||
|
fri: "friday",
|
||||||
|
sat: "saturday",
|
||||||
|
} as const satisfies Record<WeekdayShort, WeekdayLong>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ThemeVars } from "../../data/ws-themes";
|
import type { ThemeVars } from "../../data/ws-themes";
|
||||||
import { darkColorVariables } from "../../resources/theme/color";
|
import { darkColorVariables } from "../../resources/theme/color";
|
||||||
|
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
|
||||||
import { derivedStyles } from "../../resources/theme/theme";
|
import { derivedStyles } from "../../resources/theme/theme";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +53,7 @@ export const applyThemesOnElement = (
|
|||||||
|
|
||||||
if (themeToApply && darkMode) {
|
if (themeToApply && darkMode) {
|
||||||
cacheKey = `${cacheKey}__dark`;
|
cacheKey = `${cacheKey}__dark`;
|
||||||
themeRules = { ...darkColorVariables };
|
themeRules = { ...darkSemanticVariables, ...darkColorVariables };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeToApply === "default") {
|
if (themeToApply === "default") {
|
||||||
|
|||||||
36
src/common/util/parse-animation-duration.ts
Normal file
36
src/common/util/parse-animation-duration.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
|
||||||
|
*
|
||||||
|
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
|
||||||
|
* @returns The duration in milliseconds, or 0 if the input is invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseAnimationDuration("300ms") // Returns 300
|
||||||
|
* parseAnimationDuration("3s") // Returns 3000
|
||||||
|
* parseAnimationDuration("0.5s") // Returns 500
|
||||||
|
* parseAnimationDuration("invalid") // Returns 0
|
||||||
|
*/
|
||||||
|
export const parseAnimationDuration = (duration: string): number => {
|
||||||
|
const trimmed = duration.trim();
|
||||||
|
|
||||||
|
let value: number;
|
||||||
|
let multiplier: number;
|
||||||
|
|
||||||
|
if (trimmed.endsWith("ms")) {
|
||||||
|
value = parseFloat(trimmed.slice(0, -2));
|
||||||
|
multiplier = 1;
|
||||||
|
} else if (trimmed.endsWith("s")) {
|
||||||
|
value = parseFloat(trimmed.slice(0, -1));
|
||||||
|
multiplier = 1000;
|
||||||
|
} else {
|
||||||
|
// No recognized unit, try parsing as number (assume ms)
|
||||||
|
value = parseFloat(trimmed);
|
||||||
|
multiplier = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(value) || value < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * multiplier;
|
||||||
|
};
|
||||||
@@ -119,8 +119,8 @@ type Thresholds = Record<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export const DEFAULT_THRESHOLDS: Thresholds = {
|
export const DEFAULT_THRESHOLDS: Thresholds = {
|
||||||
second: 45, // seconds to minute
|
second: 59, // seconds to minute
|
||||||
minute: 45, // minutes to hour
|
minute: 59, // minutes to hour
|
||||||
hour: 22, // hour to day
|
hour: 22, // hour to day
|
||||||
day: 5, // day to week
|
day: 5, // day to week
|
||||||
week: 4, // week to months
|
week: 4, // week to months
|
||||||
|
|||||||
30
src/common/util/view-transition.ts
Normal file
30
src/common/util/view-transition.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Executes a callback within a View Transition if supported, otherwise runs it directly.
|
||||||
|
*
|
||||||
|
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
|
||||||
|
* @returns Promise that resolves when the transition completes (or immediately if not supported)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Synchronous callback
|
||||||
|
* withViewTransition(() => {
|
||||||
|
* this.large = !this.large;
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Async callback
|
||||||
|
* await withViewTransition(async () => {
|
||||||
|
* await this.updateData();
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const withViewTransition = (
|
||||||
|
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
|
||||||
|
): Promise<void> => {
|
||||||
|
if (document.startViewTransition) {
|
||||||
|
return document.startViewTransition(() => callback(true)).finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Execute callback directly without transition
|
||||||
|
const result = callback(false);
|
||||||
|
return result instanceof Promise ? result : Promise.resolve();
|
||||||
|
};
|
||||||
@@ -6,7 +6,8 @@ export function downSampleLineData<
|
|||||||
data: T[] | undefined,
|
data: T[] | undefined,
|
||||||
maxDetails: number,
|
maxDetails: number,
|
||||||
minX?: number,
|
minX?: number,
|
||||||
maxX?: number
|
maxX?: number,
|
||||||
|
useMean = false
|
||||||
): T[] {
|
): T[] {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return [];
|
return [];
|
||||||
@@ -17,15 +18,13 @@ export function downSampleLineData<
|
|||||||
const min = minX ?? getPointData(data[0]!)[0];
|
const min = minX ?? getPointData(data[0]!)[0];
|
||||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||||
const frames = new Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
min: { point: (typeof data)[number]; x: number; y: number };
|
|
||||||
max: { point: (typeof data)[number]; x: number; y: number };
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Group points into frames
|
// Group points into frames
|
||||||
|
const frames = new Map<
|
||||||
|
number,
|
||||||
|
{ point: (typeof data)[number]; x: number; y: number }[]
|
||||||
|
>();
|
||||||
|
|
||||||
for (const point of data) {
|
for (const point of data) {
|
||||||
const pointData = getPointData(point);
|
const pointData = getPointData(point);
|
||||||
if (!Array.isArray(pointData)) continue;
|
if (!Array.isArray(pointData)) continue;
|
||||||
@@ -36,28 +35,53 @@ export function downSampleLineData<
|
|||||||
const frameIndex = Math.floor((x - min) / step);
|
const frameIndex = Math.floor((x - min) / step);
|
||||||
const frame = frames.get(frameIndex);
|
const frame = frames.get(frameIndex);
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
|
frames.set(frameIndex, [{ point, x, y }]);
|
||||||
} else {
|
} else {
|
||||||
if (frame.min.y > y) {
|
frame.push({ point, x, y });
|
||||||
frame.min = { point, x, y };
|
|
||||||
}
|
|
||||||
if (frame.max.y < y) {
|
|
||||||
frame.max = { point, x, y };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert frames back to points
|
// Convert frames back to points
|
||||||
const result: T[] = [];
|
const result: T[] = [];
|
||||||
for (const [_i, frame] of frames) {
|
|
||||||
// Use min/max points to preserve visual accuracy
|
if (useMean) {
|
||||||
// The order of the data must be preserved so max may be before min
|
// Use mean values for each frame
|
||||||
if (frame.min.x > frame.max.x) {
|
for (const [_i, framePoints] of frames) {
|
||||||
result.push(frame.max.point);
|
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
|
||||||
|
const meanY = sumY / framePoints.length;
|
||||||
|
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
|
||||||
|
const meanX = sumX / framePoints.length;
|
||||||
|
|
||||||
|
const firstPoint = framePoints[0].point;
|
||||||
|
const pointData = getPointData(firstPoint);
|
||||||
|
const meanPoint = (
|
||||||
|
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
|
||||||
|
) as T;
|
||||||
|
result.push(meanPoint);
|
||||||
}
|
}
|
||||||
result.push(frame.min.point);
|
} else {
|
||||||
if (frame.min.x < frame.max.x) {
|
// Use min/max values for each frame
|
||||||
result.push(frame.max.point);
|
for (const [_i, framePoints] of frames) {
|
||||||
|
let minPoint = framePoints[0];
|
||||||
|
let maxPoint = framePoints[0];
|
||||||
|
|
||||||
|
for (const p of framePoints) {
|
||||||
|
if (p.y < minPoint.y) {
|
||||||
|
minPoint = p;
|
||||||
|
}
|
||||||
|
if (p.y > maxPoint.y) {
|
||||||
|
maxPoint = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order of the data must be preserved so max may be before min
|
||||||
|
if (minPoint.x > maxPoint.x) {
|
||||||
|
result.push(maxPoint.point);
|
||||||
|
}
|
||||||
|
result.push(minPoint.point);
|
||||||
|
if (minPoint.x < maxPoint.x) {
|
||||||
|
result.push(maxPoint.point);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export class HaChartBase extends LitElement {
|
|||||||
...axis.axisPointer?.handle,
|
...axis.axisPointer?.handle,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
|
label: { show: false },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: axis
|
: axis
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class HaDataTableLabels extends LitElement {
|
|||||||
@click=${clickAction ? this._labelClicked : undefined}
|
@click=${clickAction ? this._labelClicked : undefined}
|
||||||
@keydown=${clickAction ? this._labelClicked : undefined}
|
@keydown=${clickAction ? this._labelClicked : undefined}
|
||||||
style=${color ? `--color: ${color}` : ""}
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
>
|
>
|
||||||
${label?.icon
|
${label?.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
|
|||||||
@@ -197,9 +197,6 @@ export class HaDevicePicker extends LitElement {
|
|||||||
const placeholder =
|
const placeholder =
|
||||||
this.placeholder ??
|
this.placeholder ??
|
||||||
this.hass.localize("ui.components.device-picker.placeholder");
|
this.hass.localize("ui.components.device-picker.placeholder");
|
||||||
const notFoundLabel = this.hass.localize(
|
|
||||||
"ui.components.device-picker.no_match"
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueRenderer = this._valueRenderer(this._configEntryLookup);
|
const valueRenderer = this._valueRenderer(this._configEntryLookup);
|
||||||
|
|
||||||
@@ -209,7 +206,10 @@ export class HaDevicePicker extends LitElement {
|
|||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.searchLabel=${this.searchLabel}
|
.searchLabel=${this.searchLabel}
|
||||||
.notFoundLabel=${notFoundLabel}
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.device-picker.no_devices"
|
||||||
|
)}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
.rowRenderer=${this._rowRenderer}
|
.rowRenderer=${this._rowRenderer}
|
||||||
@@ -233,6 +233,11 @@ export class HaDevicePicker extends LitElement {
|
|||||||
this.value = value;
|
this.value = value;
|
||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.device-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -269,9 +269,6 @@ export class HaEntityPicker extends LitElement {
|
|||||||
const placeholder =
|
const placeholder =
|
||||||
this.placeholder ??
|
this.placeholder ??
|
||||||
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
||||||
const notFoundLabel = this.hass.localize(
|
|
||||||
"ui.components.entity.entity-picker.no_match"
|
|
||||||
);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-generic-picker
|
<ha-generic-picker
|
||||||
@@ -282,7 +279,7 @@ export class HaEntityPicker extends LitElement {
|
|||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.helper=${this.helper}
|
.helper=${this.helper}
|
||||||
.searchLabel=${this.searchLabel}
|
.searchLabel=${this.searchLabel}
|
||||||
.notFoundLabel=${notFoundLabel}
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.addButton ? undefined : this.value}
|
.value=${this.addButton ? undefined : this.value}
|
||||||
.rowRenderer=${this._rowRenderer}
|
.rowRenderer=${this._rowRenderer}
|
||||||
@@ -356,6 +353,11 @@ export class HaEntityPicker extends LitElement {
|
|||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.entity.entity-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import "../ha-combo-box-item";
|
|||||||
import "../ha-generic-picker";
|
import "../ha-generic-picker";
|
||||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||||
import "../ha-icon-button";
|
import "../ha-icon-button";
|
||||||
import "../ha-input-helper-text";
|
|
||||||
import type {
|
import type {
|
||||||
PickerComboBoxItem,
|
PickerComboBoxItem,
|
||||||
PickerComboBoxSearchFn,
|
PickerComboBoxSearchFn,
|
||||||
@@ -271,7 +270,6 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(isRTL ? " ◂ " : " ▸ ");
|
.join(isRTL ? " ◂ " : " ▸ ");
|
||||||
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
|
|
||||||
|
|
||||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||||
output.push({
|
output.push({
|
||||||
@@ -279,7 +277,6 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
statistic_id: id,
|
statistic_id: id,
|
||||||
primary,
|
primary,
|
||||||
secondary,
|
secondary,
|
||||||
a11y_label: a11yLabel,
|
|
||||||
stateObj: stateObj,
|
stateObj: stateObj,
|
||||||
type: "entity",
|
type: "entity",
|
||||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||||
@@ -458,9 +455,6 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
const placeholder =
|
const placeholder =
|
||||||
this.placeholder ??
|
this.placeholder ??
|
||||||
this.hass.localize("ui.components.statistic-picker.placeholder");
|
this.hass.localize("ui.components.statistic-picker.placeholder");
|
||||||
const notFoundLabel = this.hass.localize(
|
|
||||||
"ui.components.statistic-picker.no_match"
|
|
||||||
);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-generic-picker
|
<ha-generic-picker
|
||||||
@@ -468,7 +462,10 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.allowCustomValue=${this.allowCustomEntity}
|
.allowCustomValue=${this.allowCustomEntity}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.notFoundLabel=${notFoundLabel}
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.statistic-picker.no_statistics"
|
||||||
|
)}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
.rowRenderer=${this._rowRenderer}
|
.rowRenderer=${this._rowRenderer}
|
||||||
@@ -477,6 +474,7 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
.hideClearIcon=${this.hideClearIcon}
|
.hideClearIcon=${this.hideClearIcon}
|
||||||
.searchFn=${this._searchFn}
|
.searchFn=${this._searchFn}
|
||||||
.valueRenderer=${this._valueRenderer}
|
.valueRenderer=${this._valueRenderer}
|
||||||
|
.helper=${this.helper}
|
||||||
@value-changed=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
>
|
>
|
||||||
</ha-generic-picker>
|
</ha-generic-picker>
|
||||||
@@ -521,6 +519,11 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._picker?.open();
|
await this._picker?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.statistic-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -369,9 +369,10 @@ export class HaAreaPicker extends LitElement {
|
|||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.helper=${this.helper}
|
.helper=${this.helper}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.area-picker.no_match"
|
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
|
||||||
)}
|
.disabled=${this.disabled}
|
||||||
|
.required=${this.required}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
.getItems=${this._getItems}
|
.getItems=${this._getItems}
|
||||||
@@ -425,6 +426,11 @@ export class HaAreaPicker extends LitElement {
|
|||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.area-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
41
src/components/ha-dropdown-item.ts
Normal file
41
src/components/ha-dropdown-item.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
|
||||||
|
import { css, type CSSResultGroup } from "lit";
|
||||||
|
import { customElement } from "lit/decorators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home Assistant dropdown item component
|
||||||
|
*
|
||||||
|
* @element ha-dropdown-item
|
||||||
|
* @extends {DropdownItem}
|
||||||
|
*
|
||||||
|
* @summary
|
||||||
|
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@customElement("ha-dropdown-item")
|
||||||
|
export class HaDropdownItem extends DropdownItem {
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return [
|
||||||
|
DropdownItem.styles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
min-height: var(--ha-space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#icon ::slotted(*) {
|
||||||
|
color: var(--ha-color-on-neutral-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([variant="danger"]) #icon ::slotted(*) {
|
||||||
|
color: var(--ha-color-on-danger-quiet);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-dropdown-item": HaDropdownItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/components/ha-dropdown.ts
Normal file
45
src/components/ha-dropdown.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
|
||||||
|
import { css, type CSSResultGroup } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home Assistant dropdown component
|
||||||
|
*
|
||||||
|
* @element ha-dropdown
|
||||||
|
* @extends {Dropdown}
|
||||||
|
*
|
||||||
|
* @summary
|
||||||
|
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@customElement("ha-dropdown")
|
||||||
|
export class HaDropdown extends Dropdown {
|
||||||
|
@property({ attribute: false }) dropdownTag = "ha-dropdown";
|
||||||
|
|
||||||
|
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return [
|
||||||
|
Dropdown.styles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
|
||||||
|
--wa-color-surface-raised: var(
|
||||||
|
--card-background-color,
|
||||||
|
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu {
|
||||||
|
padding: var(--ha-space-1);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-dropdown": HaDropdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,7 +109,10 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
|
|||||||
.selected=${(this.value || []).includes(label.label_id)}
|
.selected=${(this.value || []).includes(label.label_id)}
|
||||||
hasMeta
|
hasMeta
|
||||||
>
|
>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon
|
? html`<ha-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
|
|||||||
@@ -383,8 +383,9 @@ export class HaFloorPicker extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.floor-picker.no_match"
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.floor-picker.no_floors"
|
||||||
)}
|
)}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
@@ -444,6 +445,11 @@ export class HaFloorPicker extends LitElement {
|
|||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.floor-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ import "./ha-svg-icon";
|
|||||||
export class HaGenericPicker extends LitElement {
|
export class HaGenericPicker extends LitElement {
|
||||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||||
|
|
||||||
// eslint-disable-next-line lit/no-native-attributes
|
|
||||||
@property({ type: Boolean }) public autofocus = false;
|
|
||||||
|
|
||||||
@property({ type: Boolean }) public disabled = false;
|
@property({ type: Boolean }) public disabled = false;
|
||||||
|
|
||||||
@property({ type: Boolean }) public required = false;
|
@property({ type: Boolean }) public required = false;
|
||||||
@@ -49,8 +46,11 @@ export class HaGenericPicker extends LitElement {
|
|||||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||||
public hideClearIcon = false;
|
public hideClearIcon = false;
|
||||||
|
|
||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false })
|
||||||
public getItems?: () => PickerComboBoxItem[];
|
public getItems?: (
|
||||||
|
searchString?: string,
|
||||||
|
section?: string
|
||||||
|
) => (PickerComboBoxItem | string)[];
|
||||||
|
|
||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false, type: Array })
|
||||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||||
@@ -64,8 +64,11 @@ export class HaGenericPicker extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||||
|
|
||||||
@property({ attribute: "not-found-label", type: String })
|
@property({ attribute: false })
|
||||||
public notFoundLabel?: string;
|
public notFoundLabel?: string | ((search: string) => string);
|
||||||
|
|
||||||
|
@property({ attribute: "empty-label" })
|
||||||
|
public emptyLabel?: string;
|
||||||
|
|
||||||
@property({ attribute: "popover-placement" })
|
@property({ attribute: "popover-placement" })
|
||||||
public popoverPlacement:
|
public popoverPlacement:
|
||||||
@@ -85,6 +88,25 @@ export class HaGenericPicker extends LitElement {
|
|||||||
/** If set picker shows an add button instead of textbox when value isn't set */
|
/** If set picker shows an add button instead of textbox when value isn't set */
|
||||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||||
|
|
||||||
|
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
|
||||||
|
@property({ attribute: false }) public sections?: (
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| "separator"
|
||||||
|
)[];
|
||||||
|
|
||||||
|
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
|
||||||
|
firstIndex: number;
|
||||||
|
lastIndex: number;
|
||||||
|
firstItem: PickerComboBoxItem | string;
|
||||||
|
secondItem: PickerComboBoxItem | string;
|
||||||
|
itemsCount: number;
|
||||||
|
}) => string | undefined;
|
||||||
|
|
||||||
|
@property({ attribute: "selected-section" }) public selectedSection?: string;
|
||||||
|
|
||||||
@query(".container") private _containerElement?: HTMLDivElement;
|
@query(".container") private _containerElement?: HTMLDivElement;
|
||||||
|
|
||||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||||
@@ -97,6 +119,11 @@ export class HaGenericPicker extends LitElement {
|
|||||||
|
|
||||||
@state() private _openedNarrow = false;
|
@state() private _openedNarrow = false;
|
||||||
|
|
||||||
|
static shadowRootOptions = {
|
||||||
|
...LitElement.shadowRootOptions,
|
||||||
|
delegatesFocus: true,
|
||||||
|
};
|
||||||
|
|
||||||
private _narrow = false;
|
private _narrow = false;
|
||||||
|
|
||||||
// helper to set new value after closing picker, to avoid flicker
|
// helper to set new value after closing picker, to avoid flicker
|
||||||
@@ -189,16 +216,19 @@ export class HaGenericPicker extends LitElement {
|
|||||||
<ha-picker-combo-box
|
<ha-picker-combo-box
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.allowCustomValue=${this.allowCustomValue}
|
.allowCustomValue=${this.allowCustomValue}
|
||||||
.label=${this.searchLabel ??
|
.label=${this.searchLabel}
|
||||||
(this.hass?.localize("ui.common.search") || "Search")}
|
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
@value-changed=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
.rowRenderer=${this.rowRenderer}
|
.rowRenderer=${this.rowRenderer}
|
||||||
.notFoundLabel=${this.notFoundLabel}
|
.notFoundLabel=${this.notFoundLabel}
|
||||||
|
.emptyLabel=${this.emptyLabel}
|
||||||
.getItems=${this.getItems}
|
.getItems=${this.getItems}
|
||||||
.getAdditionalItems=${this.getAdditionalItems}
|
.getAdditionalItems=${this.getAdditionalItems}
|
||||||
.searchFn=${this.searchFn}
|
.searchFn=${this.searchFn}
|
||||||
.mode=${dialogMode ? "dialog" : "popover"}
|
.mode=${dialogMode ? "dialog" : "popover"}
|
||||||
|
.sections=${this.sections}
|
||||||
|
.sectionTitleFunction=${this.sectionTitleFunction}
|
||||||
|
.selectedSection=${this.selectedSection}
|
||||||
></ha-picker-combo-box>
|
></ha-picker-combo-box>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,8 +224,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.label-picker.no_match"
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.label-picker.no_labels"
|
||||||
)}
|
)}
|
||||||
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
|
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
@@ -288,6 +289,11 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
|||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.label-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property } from "lit/decorators";
|
||||||
|
import { uid } from "../common/util/uid";
|
||||||
|
import "./ha-tooltip";
|
||||||
|
|
||||||
@customElement("ha-label")
|
@customElement("ha-label")
|
||||||
class HaLabel extends LitElement {
|
class HaLabel extends LitElement {
|
||||||
@property({ type: Boolean, reflect: true }) dense = false;
|
@property({ type: Boolean, reflect: true }) dense = false;
|
||||||
|
|
||||||
|
@property({ attribute: "description" })
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
private _elementId = "label-" + uid();
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<span class="content">
|
<ha-tooltip
|
||||||
<slot name="icon"></slot>
|
.for=${this._elementId}
|
||||||
<slot></slot>
|
.disabled=${!this.description?.trim()}
|
||||||
</span>
|
>
|
||||||
|
${this.description}
|
||||||
|
</ha-tooltip>
|
||||||
|
<div class="container" .id=${this._elementId}>
|
||||||
|
<span class="content">
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +51,7 @@ class HaLabel extends LitElement {
|
|||||||
font-weight: var(--ha-font-weight-medium);
|
font-weight: var(--ha-font-weight-medium);
|
||||||
line-height: var(--ha-line-height-condensed);
|
line-height: var(--ha-line-height-condensed);
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
vertical-align: middle;
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: var(--ha-border-radius-xl);
|
border-radius: var(--ha-border-radius-xl);
|
||||||
color: var(--ha-label-text-color);
|
color: var(--ha-label-text-color);
|
||||||
--mdc-icon-size: 12px;
|
--mdc-icon-size: 12px;
|
||||||
@@ -66,15 +79,24 @@ class HaLabel extends LitElement {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([dense]) {
|
:host([dense]) {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: var(--ha-border-radius-md);
|
border-radius: var(--ha-border-radius-md);
|
||||||
}
|
}
|
||||||
|
:host([dense]) .container {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
:host([dense]) ::slotted([slot="icon"]) {
|
:host([dense]) ::slotted([slot="icon"]) {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import "./chips/ha-input-chip";
|
|||||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||||
import "./ha-label-picker";
|
import "./ha-label-picker";
|
||||||
import type { HaLabelPicker } from "./ha-label-picker";
|
import type { HaLabelPicker } from "./ha-label-picker";
|
||||||
|
import "./ha-tooltip";
|
||||||
|
|
||||||
@customElement("ha-labels-picker")
|
@customElement("ha-labels-picker")
|
||||||
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
||||||
@@ -142,9 +143,17 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
|||||||
const color = label?.color
|
const color = label?.color
|
||||||
? computeCssColor(label.color)
|
? computeCssColor(label.color)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const elementId = "label-" + label.label_id;
|
||||||
return html`
|
return html`
|
||||||
|
<ha-tooltip
|
||||||
|
.for=${elementId}
|
||||||
|
.disabled=${!label?.description?.trim()}
|
||||||
|
>
|
||||||
|
${label?.description}
|
||||||
|
</ha-tooltip>
|
||||||
<ha-input-chip
|
<ha-input-chip
|
||||||
.item=${label}
|
.item=${label}
|
||||||
|
.id=${elementId}
|
||||||
@remove=${this._removeItem}
|
@remove=${this._removeItem}
|
||||||
@click=${this._openDetail}
|
@click=${this._openDetail}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
|
|||||||
@@ -125,9 +125,10 @@ export class HaLanguagePicker extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
popover-placement="bottom-end"
|
popover-placement="bottom-end"
|
||||||
.notFoundLabel=${this.hass?.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.language-picker.no_match"
|
.emptyLabel=${this.hass?.localize(
|
||||||
)}
|
"ui.components.language-picker.no_languages"
|
||||||
|
) || "No languages available"}
|
||||||
.placeholder=${this.label ??
|
.placeholder=${this.label ??
|
||||||
(this.hass?.localize("ui.components.language-picker.language") ||
|
(this.hass?.localize("ui.components.language-picker.language") ||
|
||||||
"Language")}
|
"Language")}
|
||||||
@@ -172,6 +173,15 @@ export class HaLanguagePicker extends LitElement {
|
|||||||
this.value = ev.detail.value;
|
this.value = ev.detail.value;
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) => {
|
||||||
|
const term = html`<b>‘${search}’</b>`;
|
||||||
|
return this.hass
|
||||||
|
? this.hass.localize("ui.components.language-picker.no_match", {
|
||||||
|
term,
|
||||||
|
})
|
||||||
|
: html`No languages found for ${term}`;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
|
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
|
||||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
|
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
|
||||||
import { css } from "lit";
|
import { css, html, nothing, type TemplateResult } from "lit";
|
||||||
import { customElement } from "lit/decorators";
|
import { customElement } from "lit/decorators";
|
||||||
|
import "./ha-ripple";
|
||||||
|
|
||||||
export const haMdListStyles = [
|
export const haMdListStyles = [
|
||||||
styles,
|
styles,
|
||||||
@@ -25,6 +26,18 @@ export const haMdListStyles = [
|
|||||||
@customElement("ha-md-list-item")
|
@customElement("ha-md-list-item")
|
||||||
export class HaMdListItem extends ListItemEl {
|
export class HaMdListItem extends ListItemEl {
|
||||||
static override styles = haMdListStyles;
|
static override styles = haMdListStyles;
|
||||||
|
|
||||||
|
protected renderRipple(): TemplateResult | typeof nothing {
|
||||||
|
if (this.type === "text") {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<ha-ripple
|
||||||
|
part="ripple"
|
||||||
|
for="item"
|
||||||
|
?disabled=${this.disabled && this.type !== "link"}
|
||||||
|
></ha-ripple>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||||
import { mdiMagnify } from "@mdi/js";
|
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import {
|
import {
|
||||||
@@ -14,11 +14,12 @@ import memoizeOne from "memoize-one";
|
|||||||
import { tinykeys } from "tinykeys";
|
import { tinykeys } from "tinykeys";
|
||||||
import { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||||
import type { LocalizeFunc } from "../common/translations/localize";
|
|
||||||
import { HaFuse } from "../resources/fuse";
|
import { HaFuse } from "../resources/fuse";
|
||||||
import { haStyleScrollbar } from "../resources/styles";
|
import { haStyleScrollbar } from "../resources/styles";
|
||||||
import { loadVirtualizer } from "../resources/virtualizer";
|
import { loadVirtualizer } from "../resources/virtualizer";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
import "./chips/ha-chip-set";
|
||||||
|
import "./chips/ha-filter-chip";
|
||||||
import "./ha-combo-box-item";
|
import "./ha-combo-box-item";
|
||||||
import "./ha-icon";
|
import "./ha-icon";
|
||||||
import "./ha-textfield";
|
import "./ha-textfield";
|
||||||
@@ -27,28 +28,18 @@ import type { HaTextField } from "./ha-textfield";
|
|||||||
export interface PickerComboBoxItem {
|
export interface PickerComboBoxItem {
|
||||||
id: string;
|
id: string;
|
||||||
primary: string;
|
primary: string;
|
||||||
a11y_label?: string;
|
|
||||||
secondary?: string;
|
secondary?: string;
|
||||||
search_labels?: string[];
|
search_labels?: string[];
|
||||||
sorting_label?: string;
|
sorting_label?: string;
|
||||||
icon_path?: string;
|
icon_path?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||||
// Hack to force empty label to always display empty value by default in the search field
|
|
||||||
export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
|
|
||||||
a11y_label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
|
|
||||||
|
|
||||||
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
|
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
|
||||||
item
|
item
|
||||||
) => html`
|
) => html`
|
||||||
<ha-combo-box-item
|
<ha-combo-box-item type="button" compact>
|
||||||
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
${item.icon
|
${item.icon
|
||||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||||
: item.icon_path
|
: item.icon_path
|
||||||
@@ -87,8 +78,11 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
|
|
||||||
@state() private _listScrolled = false;
|
@state() private _listScrolled = false;
|
||||||
|
|
||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false })
|
||||||
public getItems?: () => PickerComboBoxItem[];
|
public getItems?: (
|
||||||
|
searchString?: string,
|
||||||
|
section?: string
|
||||||
|
) => (PickerComboBoxItem | string)[];
|
||||||
|
|
||||||
@property({ attribute: false, type: Array })
|
@property({ attribute: false, type: Array })
|
||||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||||
@@ -96,21 +90,45 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
|
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
|
||||||
|
|
||||||
@property({ attribute: "not-found-label", type: String })
|
@property({ attribute: false })
|
||||||
public notFoundLabel?: string;
|
public notFoundLabel?: string | ((search: string) => string);
|
||||||
|
|
||||||
|
@property({ attribute: "empty-label" })
|
||||||
|
public emptyLabel?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||||
|
|
||||||
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
||||||
|
|
||||||
|
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
|
||||||
|
@property({ attribute: false }) public sections?: (
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
| "separator"
|
||||||
|
)[];
|
||||||
|
|
||||||
|
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
|
||||||
|
firstIndex: number;
|
||||||
|
lastIndex: number;
|
||||||
|
firstItem: PickerComboBoxItem | string;
|
||||||
|
secondItem: PickerComboBoxItem | string;
|
||||||
|
itemsCount: number;
|
||||||
|
}) => string | undefined;
|
||||||
|
|
||||||
|
@property({ attribute: "selected-section" }) public selectedSection?: string;
|
||||||
|
|
||||||
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
|
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
|
||||||
|
|
||||||
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
||||||
|
|
||||||
@state() private _items: PickerComboBoxItemWithLabel[] = [];
|
@state() private _items: (PickerComboBoxItem | string)[] = [];
|
||||||
|
|
||||||
private _allItems: PickerComboBoxItemWithLabel[] = [];
|
@state() private _sectionTitle?: string;
|
||||||
|
|
||||||
|
private _allItems: (PickerComboBoxItem | string)[] = [];
|
||||||
|
|
||||||
private _selectedItemIndex = -1;
|
private _selectedItemIndex = -1;
|
||||||
|
|
||||||
@@ -121,6 +139,8 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
|
|
||||||
private _removeKeyboardShortcuts?: () => void;
|
private _removeKeyboardShortcuts?: () => void;
|
||||||
|
|
||||||
|
private _search = "";
|
||||||
|
|
||||||
protected firstUpdated() {
|
protected firstUpdated() {
|
||||||
this._registerKeyboardShortcuts();
|
this._registerKeyboardShortcuts();
|
||||||
}
|
}
|
||||||
@@ -145,74 +165,142 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
"Search"}
|
"Search"}
|
||||||
@input=${this._filterChanged}
|
@input=${this._filterChanged}
|
||||||
></ha-textfield>
|
></ha-textfield>
|
||||||
|
${this._renderSectionButtons()}
|
||||||
|
${this.sections?.length
|
||||||
|
? html`
|
||||||
|
<div class="section-title-wrapper">
|
||||||
|
<div
|
||||||
|
class="section-title ${!this.selectedSection &&
|
||||||
|
this._sectionTitle
|
||||||
|
? "show"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
${this._sectionTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
@scroll=${this._onScrollList}
|
.keyFunction=${this._keyFunction}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
scroller
|
scroller
|
||||||
.items=${this._items}
|
.items=${this._items}
|
||||||
.renderItem=${this._renderItem}
|
.renderItem=${this._renderItem}
|
||||||
style="min-height: 36px;"
|
style="min-height: 36px;"
|
||||||
class=${this._listScrolled ? "scrolled" : ""}
|
class=${this._listScrolled ? "scrolled" : ""}
|
||||||
|
@scroll=${this._onScrollList}
|
||||||
@focus=${this._focusList}
|
@focus=${this._focusList}
|
||||||
|
@visibilityChanged=${this._visibilityChanged}
|
||||||
>
|
>
|
||||||
</lit-virtualizer> `;
|
</lit-virtualizer> `;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _defaultNotFoundItem = memoizeOne(
|
private _renderSectionButtons() {
|
||||||
(
|
if (!this.sections || this.sections.length === 0) {
|
||||||
label: this["notFoundLabel"],
|
return nothing;
|
||||||
localize?: LocalizeFunc
|
}
|
||||||
): PickerComboBoxItemWithLabel => ({
|
|
||||||
id: NO_MATCHING_ITEMS_FOUND_ID,
|
|
||||||
primary:
|
|
||||||
label ||
|
|
||||||
(localize && localize("ui.components.combo-box.no_match")) ||
|
|
||||||
"No matching items found",
|
|
||||||
icon_path: mdiMagnify,
|
|
||||||
a11y_label:
|
|
||||||
label ||
|
|
||||||
(localize && localize("ui.components.combo-box.no_match")) ||
|
|
||||||
"No matching items found",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
private _getAdditionalItems = (searchString?: string) => {
|
return html`
|
||||||
const items = this.getAdditionalItems?.(searchString) || [];
|
<ha-chip-set class="sections">
|
||||||
|
${this.sections.map((section) =>
|
||||||
|
section === "separator"
|
||||||
|
? html`<div class="separator"></div>`
|
||||||
|
: html`<ha-filter-chip
|
||||||
|
@click=${this._toggleSection}
|
||||||
|
.section-id=${section.id}
|
||||||
|
.selected=${this.selectedSection === section.id}
|
||||||
|
.label=${section.label}
|
||||||
|
>
|
||||||
|
</ha-filter-chip>`
|
||||||
|
)}
|
||||||
|
</ha-chip-set>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return items.map<PickerComboBoxItemWithLabel>((item) => ({
|
@eventOptions({ passive: true })
|
||||||
...item,
|
private _visibilityChanged(ev) {
|
||||||
a11y_label: item.a11y_label || item.primary,
|
if (
|
||||||
}));
|
this._virtualizerElement &&
|
||||||
};
|
this.sectionTitleFunction &&
|
||||||
|
this.sections?.length
|
||||||
|
) {
|
||||||
|
const firstItem = this._virtualizerElement.items[ev.first];
|
||||||
|
const secondItem = this._virtualizerElement.items[ev.first + 1];
|
||||||
|
this._sectionTitle = this.sectionTitleFunction({
|
||||||
|
firstIndex: ev.first,
|
||||||
|
lastIndex: ev.last,
|
||||||
|
firstItem: firstItem as PickerComboBoxItem | string,
|
||||||
|
secondItem: secondItem as PickerComboBoxItem | string,
|
||||||
|
itemsCount: this._virtualizerElement.items.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _getItems = (): PickerComboBoxItemWithLabel[] => {
|
private _getAdditionalItems = (searchString?: string) =>
|
||||||
const items = this.getItems ? this.getItems() : [];
|
this.getAdditionalItems?.(searchString) || [];
|
||||||
|
|
||||||
const sortedItems = items
|
private _getItems = () => {
|
||||||
.map<PickerComboBoxItemWithLabel>((item) => ({
|
let items = [
|
||||||
...item,
|
...(this.getItems
|
||||||
a11y_label: item.a11y_label || item.primary,
|
? this.getItems(this._search, this.selectedSection)
|
||||||
}))
|
: []),
|
||||||
.sort((entityA, entityB) =>
|
];
|
||||||
|
|
||||||
|
if (!this.sections?.length) {
|
||||||
|
items = items.sort((entityA, entityB) =>
|
||||||
caseInsensitiveStringCompare(
|
caseInsensitiveStringCompare(
|
||||||
entityA.sorting_label!,
|
(entityA as PickerComboBoxItem).sorting_label!,
|
||||||
entityB.sorting_label!,
|
(entityB as PickerComboBoxItem).sorting_label!,
|
||||||
this.hass?.locale.language ?? navigator.language
|
this.hass?.locale.language ?? navigator.language
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!sortedItems.length) {
|
if (!items.length) {
|
||||||
sortedItems.push(
|
items.push(NO_ITEMS_AVAILABLE_ID);
|
||||||
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalItems = this._getAdditionalItems();
|
const additionalItems = this._getAdditionalItems();
|
||||||
sortedItems.push(...additionalItems);
|
items.push(...additionalItems);
|
||||||
return sortedItems;
|
|
||||||
|
if (this.mode === "dialog") {
|
||||||
|
items.push("padding"); // padding for safe area inset
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
private _renderItem = (item: PickerComboBoxItem, index: number) => {
|
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
|
||||||
|
if (item === "padding") {
|
||||||
|
return html`<div class="bottom-padding"></div>`;
|
||||||
|
}
|
||||||
|
if (item === NO_ITEMS_AVAILABLE_ID) {
|
||||||
|
return html`
|
||||||
|
<div class="combo-box-row">
|
||||||
|
<ha-combo-box-item type="text" compact>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${this._search ? mdiMagnify : mdiMinusBoxOutline}
|
||||||
|
></ha-svg-icon>
|
||||||
|
<span slot="headline"
|
||||||
|
>${this._search
|
||||||
|
? typeof this.notFoundLabel === "function"
|
||||||
|
? this.notFoundLabel(this._search)
|
||||||
|
: this.notFoundLabel ||
|
||||||
|
this.hass?.localize("ui.components.combo-box.no_match") ||
|
||||||
|
"No matching items found"
|
||||||
|
: this.emptyLabel ||
|
||||||
|
this.hass?.localize("ui.components.combo-box.no_items") ||
|
||||||
|
"No items available"}</span
|
||||||
|
>
|
||||||
|
</ha-combo-box-item>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return html`<div class="title">${item}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
|
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
|
||||||
return html`<div
|
return html`<div
|
||||||
id=${`list-item-${index}`}
|
id=${`list-item-${index}`}
|
||||||
@@ -221,9 +309,7 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
.index=${index}
|
.index=${index}
|
||||||
@click=${this._valueSelected}
|
@click=${this._valueSelected}
|
||||||
>
|
>
|
||||||
${item.id === NO_MATCHING_ITEMS_FOUND_ID
|
${renderer(item, index)}
|
||||||
? DEFAULT_ROW_RENDERER(item, index)
|
|
||||||
: renderer(item, index)}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
const value = (ev.currentTarget as any).value as string;
|
const value = (ev.currentTarget as any).value as string;
|
||||||
const newValue = value?.trim();
|
const newValue = value?.trim();
|
||||||
|
|
||||||
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(this, "value-changed", { value: newValue });
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
private _filterChanged = (ev: Event) => {
|
private _filterChanged = (ev: Event) => {
|
||||||
const textfield = ev.target as HaTextField;
|
const textfield = ev.target as HaTextField;
|
||||||
const searchString = textfield.value.trim();
|
const searchString = textfield.value.trim();
|
||||||
|
this._search = searchString;
|
||||||
|
|
||||||
if (!searchString) {
|
if (this.sections?.length) {
|
||||||
this._items = this._allItems;
|
this._items = this._getItems();
|
||||||
return;
|
} else {
|
||||||
}
|
if (!searchString) {
|
||||||
|
this._items = this._allItems;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const index = this._fuseIndex(this._allItems);
|
const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
|
||||||
const fuse = new HaFuse(
|
const fuse = new HaFuse(
|
||||||
this._allItems,
|
this._allItems as PickerComboBoxItem[],
|
||||||
{
|
{
|
||||||
shouldSort: false,
|
shouldSort: false,
|
||||||
minMatchCharLength: Math.min(searchString.length, 2),
|
minMatchCharLength: Math.min(searchString.length, 2),
|
||||||
},
|
},
|
||||||
index
|
index
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = fuse.multiTermsSearch(searchString);
|
const results = fuse.multiTermsSearch(searchString);
|
||||||
let filteredItems = this._allItems as PickerComboBoxItem[];
|
let filteredItems = [...this._allItems];
|
||||||
if (results) {
|
|
||||||
const items = results.map((result) => result.item);
|
if (results) {
|
||||||
if (items.length === 0) {
|
const items: (PickerComboBoxItem | string)[] = results.map(
|
||||||
items.push(
|
(result) => result.item
|
||||||
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
|
);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalItems = this._getAdditionalItems();
|
||||||
|
items.push(...additionalItems);
|
||||||
|
|
||||||
|
filteredItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchFn) {
|
||||||
|
filteredItems = this.searchFn(
|
||||||
|
searchString,
|
||||||
|
filteredItems as PickerComboBoxItem[],
|
||||||
|
this._allItems as PickerComboBoxItem[]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const additionalItems = this._getAdditionalItems(searchString);
|
|
||||||
items.push(...additionalItems);
|
this._items = filteredItems as PickerComboBoxItem[];
|
||||||
filteredItems = items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.searchFn) {
|
|
||||||
filteredItems = this.searchFn(
|
|
||||||
searchString,
|
|
||||||
filteredItems,
|
|
||||||
this._allItems
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._items = filteredItems as PickerComboBoxItemWithLabel[];
|
|
||||||
this._selectedItemIndex = -1;
|
this._selectedItemIndex = -1;
|
||||||
if (this._virtualizerElement) {
|
if (this._virtualizerElement) {
|
||||||
this._virtualizerElement.scrollTo(0, 0);
|
this._virtualizerElement.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _toggleSection(ev: Event) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this._resetSelectedItem();
|
||||||
|
this._sectionTitle = undefined;
|
||||||
|
const section = (ev.target as HTMLElement)["section-id"] as string;
|
||||||
|
if (!section) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedSection === section) {
|
||||||
|
this.selectedSection = undefined;
|
||||||
|
} else {
|
||||||
|
this.selectedSection = section;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._items = this._getItems();
|
||||||
|
|
||||||
|
// Reset scroll position when filter changes
|
||||||
|
if (this._virtualizerElement) {
|
||||||
|
this._virtualizerElement.scrollToIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _registerKeyboardShortcuts() {
|
private _registerKeyboardShortcuts() {
|
||||||
this._removeKeyboardShortcuts = tinykeys(this, {
|
this._removeKeyboardShortcuts = tinykeys(this, {
|
||||||
ArrowUp: this._selectPreviousItem,
|
ArrowUp: this._selectPreviousItem,
|
||||||
@@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
|
if (typeof items[nextIndex] === "string") {
|
||||||
// Skip titles, padding and empty search
|
// Skip titles, padding and empty search
|
||||||
if (nextIndex === maxItems) {
|
if (nextIndex === maxItems) {
|
||||||
return;
|
return;
|
||||||
@@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
|
if (typeof items[nextIndex] === "string") {
|
||||||
// Skip titles, padding and empty search
|
// Skip titles, padding and empty search
|
||||||
if (nextIndex === 0) {
|
if (nextIndex === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
|
|
||||||
const nextIndex = 0;
|
const nextIndex = 0;
|
||||||
|
|
||||||
if (
|
|
||||||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
|
||||||
NO_MATCHING_ITEMS_FOUND_ID
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||||||
this._selectedItemIndex = nextIndex + 1;
|
this._selectedItemIndex = nextIndex + 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
|
|
||||||
const nextIndex = this._virtualizerElement.items.length - 1;
|
const nextIndex = this._virtualizerElement.items.length - 1;
|
||||||
|
|
||||||
if (
|
|
||||||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
|
||||||
NO_MATCHING_ITEMS_FOUND_ID
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||||||
this._selectedItemIndex = nextIndex - 1;
|
this._selectedItemIndex = nextIndex - 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
|
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
|
||||||
|
|
||||||
if (
|
if (this._virtualizerElement?.items.length === 1) {
|
||||||
this._virtualizerElement?.items.length === 1 &&
|
|
||||||
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
|
|
||||||
) {
|
|
||||||
fireEvent(this, "value-changed", {
|
fireEvent(this, "value-changed", {
|
||||||
value: firstItem.id,
|
value: firstItem.id,
|
||||||
});
|
});
|
||||||
@@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
const item = this._virtualizerElement?.items[
|
const item = this._virtualizerElement?.items[
|
||||||
this._selectedItemIndex
|
this._selectedItemIndex
|
||||||
] as PickerComboBoxItem;
|
] as PickerComboBoxItem;
|
||||||
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
|
if (item) {
|
||||||
fireEvent(this, "value-changed", { value: item.id });
|
fireEvent(this, "value-changed", { value: item.id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
this._selectedItemIndex = -1;
|
this._selectedItemIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _keyFunction = (item: PickerComboBoxItem | string) =>
|
||||||
|
typeof item === "string" ? item : item.id;
|
||||||
|
|
||||||
static styles = [
|
static styles = [
|
||||||
haStyleScrollbar,
|
haStyleScrollbar,
|
||||||
css`
|
css`
|
||||||
@@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement {
|
|||||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: var(--ha-space-2);
|
||||||
|
padding: var(--ha-space-3) var(--ha-space-3);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([mode="dialog"]) .sections {
|
||||||
|
padding: var(--ha-space-3) var(--ha-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections ha-filter-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
--md-filter-chip-selected-container-color: var(
|
||||||
|
--ha-color-fill-primary-normal-hover
|
||||||
|
);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections .separator {
|
||||||
|
height: var(--ha-space-8);
|
||||||
|
width: 0;
|
||||||
|
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title,
|
||||||
|
.title {
|
||||||
|
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||||
|
padding: var(--ha-space-1) var(--ha-space-2);
|
||||||
|
font-weight: var(--ha-font-weight-bold);
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
min-height: var(--ha-space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([mode="dialog"]) .title {
|
||||||
|
padding: var(--ha-space-1) var(--ha-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([mode="dialog"]) ha-textfield {
|
||||||
|
padding: 0 var(--ha-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-wrapper {
|
||||||
|
height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
width: calc(100% - var(--ha-space-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title.show {
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-search {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--ha-space-3);
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||||
import { html, LitElement } from "lit";
|
import { html, LitElement } from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import type { StateSelector } from "../../data/selector";
|
import type { StateSelector } from "../../data/selector";
|
||||||
|
import { extractFromTarget } from "../../data/target";
|
||||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import "../entity/ha-entity-state-picker";
|
import "../entity/ha-entity-state-picker";
|
||||||
@@ -25,15 +27,29 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
|||||||
@property({ attribute: false }) public context?: {
|
@property({ attribute: false }) public context?: {
|
||||||
filter_attribute?: string;
|
filter_attribute?: string;
|
||||||
filter_entity?: string | string[];
|
filter_entity?: string | string[];
|
||||||
|
filter_target?: HassServiceTarget;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@state() private _entityIds?: string | string[];
|
||||||
|
|
||||||
|
willUpdate(changedProps) {
|
||||||
|
if (changedProps.has("selector") || changedProps.has("context")) {
|
||||||
|
this._resolveEntityIds(
|
||||||
|
this.selector.state?.entity_id,
|
||||||
|
this.context?.filter_entity,
|
||||||
|
this.context?.filter_target
|
||||||
|
).then((entityIds) => {
|
||||||
|
this._entityIds = entityIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (this.selector.state?.multiple) {
|
if (this.selector.state?.multiple) {
|
||||||
return html`
|
return html`
|
||||||
<ha-entity-states-picker
|
<ha-entity-states-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityId=${this.selector.state?.entity_id ||
|
.entityId=${this._entityIds}
|
||||||
this.context?.filter_entity}
|
|
||||||
.attribute=${this.selector.state?.attribute ||
|
.attribute=${this.selector.state?.attribute ||
|
||||||
this.context?.filter_attribute}
|
this.context?.filter_attribute}
|
||||||
.extraOptions=${this.selector.state?.extra_options}
|
.extraOptions=${this.selector.state?.extra_options}
|
||||||
@@ -50,8 +66,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
|||||||
return html`
|
return html`
|
||||||
<ha-entity-state-picker
|
<ha-entity-state-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityId=${this.selector.state?.entity_id ||
|
.entityId=${this._entityIds}
|
||||||
this.context?.filter_entity}
|
|
||||||
.attribute=${this.selector.state?.attribute ||
|
.attribute=${this.selector.state?.attribute ||
|
||||||
this.context?.filter_attribute}
|
this.context?.filter_attribute}
|
||||||
.extraOptions=${this.selector.state?.extra_options}
|
.extraOptions=${this.selector.state?.extra_options}
|
||||||
@@ -65,6 +80,24 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
|||||||
></ha-entity-state-picker>
|
></ha-entity-state-picker>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _resolveEntityIds(
|
||||||
|
selectorEntityId: string | string[] | undefined,
|
||||||
|
contextFilterEntity: string | string[] | undefined,
|
||||||
|
contextFilterTarget: HassServiceTarget | undefined
|
||||||
|
): Promise<string | string[] | undefined> {
|
||||||
|
if (selectorEntityId !== undefined) {
|
||||||
|
return selectorEntityId;
|
||||||
|
}
|
||||||
|
if (contextFilterEntity !== undefined) {
|
||||||
|
return contextFilterEntity;
|
||||||
|
}
|
||||||
|
if (contextFilterTarget !== undefined) {
|
||||||
|
const result = await extractFromTarget(this.hass, contextFilterTarget);
|
||||||
|
return result.referenced_entities;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ export const computePanels = memoizeOne(
|
|||||||
if (
|
if (
|
||||||
hiddenPanels.includes(panel.url_path) ||
|
hiddenPanels.includes(panel.url_path) ||
|
||||||
(!panel.title && panel.url_path !== defaultPanel) ||
|
(!panel.title && panel.url_path !== defaultPanel) ||
|
||||||
(!panel.default_visible && !panelsOrder.includes(panel.url_path))
|
(panel.default_visible === false &&
|
||||||
|
!panelsOrder.includes(panel.url_path))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||||
|
import { consume } from "@lit/context";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
|
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
|
||||||
import { mdiPlaylistPlus } from "@mdi/js";
|
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||||
import type { CSSResultGroup } from "lit";
|
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||||
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
|
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { styleMap } from "lit/directives/style-map";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
import { ensureArray } from "../common/array/ensure-array";
|
import { ensureArray } from "../common/array/ensure-array";
|
||||||
import { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { isValidEntityId } from "../common/entity/valid_entity_id";
|
import { isValidEntityId } from "../common/entity/valid_entity_id";
|
||||||
|
import { computeRTL } from "../common/util/compute_rtl";
|
||||||
|
import {
|
||||||
|
getAreasAndFloors,
|
||||||
|
type AreaFloorValue,
|
||||||
|
type FloorComboBoxItem,
|
||||||
|
} from "../data/area_floor";
|
||||||
|
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||||||
|
import { labelsContext } from "../data/context";
|
||||||
|
import { getDevices, type DevicePickerItem } from "../data/device_registry";
|
||||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
|
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
|
||||||
|
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
|
||||||
|
import { domainToName } from "../data/integration";
|
||||||
|
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
|
||||||
import {
|
import {
|
||||||
areaMeetsFilter,
|
areaMeetsFilter,
|
||||||
deviceMeetsFilter,
|
deviceMeetsFilter,
|
||||||
@@ -18,18 +34,23 @@ import {
|
|||||||
type TargetTypeFloorless,
|
type TargetTypeFloorless,
|
||||||
} from "../data/target";
|
} from "../data/target";
|
||||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||||
|
import { isHelperDomain } from "../panels/config/helpers/const";
|
||||||
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
|
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
|
||||||
|
import { HaFuse } from "../resources/fuse";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
import { brandsUrl } from "../util/brands-url";
|
||||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||||
import "./ha-bottom-sheet";
|
import "./ha-generic-picker";
|
||||||
import "./ha-button";
|
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||||
import "./ha-input-helper-text";
|
|
||||||
import "./ha-svg-icon";
|
import "./ha-svg-icon";
|
||||||
|
import "./ha-tree-indicator";
|
||||||
import "./target-picker/ha-target-picker-item-group";
|
import "./target-picker/ha-target-picker-item-group";
|
||||||
import "./target-picker/ha-target-picker-selector";
|
|
||||||
import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector";
|
|
||||||
import "./target-picker/ha-target-picker-value-chip";
|
import "./target-picker/ha-target-picker-value-chip";
|
||||||
|
|
||||||
|
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
|
||||||
|
const SEPARATOR = "________";
|
||||||
|
const CREATE_ID = "___create-new-entity___";
|
||||||
|
|
||||||
@customElement("ha-target-picker")
|
@customElement("ha-target-picker")
|
||||||
export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
|
|
||||||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||||||
|
|
||||||
@state() private _open = false;
|
@state() private _selectedSection?: TargetTypeFloorless;
|
||||||
|
|
||||||
@state() private _addTargetWidth = 0;
|
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||||
|
|
||||||
@state() private _narrow = false;
|
@state()
|
||||||
|
@consume({ context: labelsContext, subscribe: true })
|
||||||
@state() private _pickerFilter?: TargetTypeFloorless;
|
private _labelRegistry!: LabelRegistryEntry[];
|
||||||
|
|
||||||
@state() private _pickerWrapperOpen = false;
|
|
||||||
|
|
||||||
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
|
|
||||||
|
|
||||||
@query("ha-target-picker-selector")
|
|
||||||
private _targetPickerSelectorElement?: HaTargetPickerSelector;
|
|
||||||
|
|
||||||
private _newTarget?: { type: TargetType; id: string };
|
private _newTarget?: { type: TargetType; id: string };
|
||||||
|
|
||||||
|
private _getDevicesMemoized = memoizeOne(getDevices);
|
||||||
|
|
||||||
|
private _getLabelsMemoized = memoizeOne(getLabels);
|
||||||
|
|
||||||
|
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||||
|
|
||||||
|
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||||
|
|
||||||
|
private get _showEntityId() {
|
||||||
|
return this.hass.userData?.showEntityIdPicker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _fuseIndexes = {
|
||||||
|
area: memoizeOne((states: FloorComboBoxItem[]) =>
|
||||||
|
this._createFuseIndex(states)
|
||||||
|
),
|
||||||
|
entity: memoizeOne((states: EntityComboBoxItem[]) =>
|
||||||
|
this._createFuseIndex(states)
|
||||||
|
),
|
||||||
|
device: memoizeOne((states: DevicePickerItem[]) =>
|
||||||
|
this._createFuseIndex(states)
|
||||||
|
),
|
||||||
|
label: memoizeOne((states: PickerComboBoxItem[]) =>
|
||||||
|
this._createFuseIndex(states)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
|
super.willUpdate(changedProps);
|
||||||
|
|
||||||
|
if (!this.hasUpdated) {
|
||||||
|
this._loadConfigEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createFuseIndex = (states) =>
|
||||||
|
Fuse.createIndex(["search_labels"], states);
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (this.addOnTop) {
|
if (this.addOnTop) {
|
||||||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||||||
@@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _renderPicker() {
|
private _renderPicker() {
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
id: "entity",
|
||||||
|
label: this.hass.localize("ui.components.target-picker.type.entities"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "device",
|
||||||
|
label: this.hass.localize("ui.components.target-picker.type.devices"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "area",
|
||||||
|
label: this.hass.localize("ui.components.target-picker.type.areas"),
|
||||||
|
},
|
||||||
|
"separator" as const,
|
||||||
|
{
|
||||||
|
id: "label",
|
||||||
|
label: this.hass.localize("ui.components.target-picker.type.labels"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="add-target-wrapper">
|
<div class="add-target-wrapper">
|
||||||
<ha-button
|
<ha-generic-picker
|
||||||
id="add-target-button"
|
.hass=${this.hass}
|
||||||
size="small"
|
.disabled=${this.disabled}
|
||||||
appearance="filled"
|
.autofocus=${this.autofocus}
|
||||||
@click=${this._showPicker}
|
.helper=${this.helper}
|
||||||
|
.sections=${sections}
|
||||||
|
.notFoundLabel=${this._noTargetFoundLabel}
|
||||||
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.target-picker.no_targets"
|
||||||
|
)}
|
||||||
|
.sectionTitleFunction=${this._sectionTitleFunction}
|
||||||
|
.selectedSection=${this._selectedSection}
|
||||||
|
.rowRenderer=${this._renderRow}
|
||||||
|
.getItems=${this._getItems}
|
||||||
|
@value-changed=${this._targetPicked}
|
||||||
|
.addButtonLabel=${this.hass.localize(
|
||||||
|
"ui.components.target-picker.add_target"
|
||||||
|
)}
|
||||||
|
.getAdditionalItems=${this._getAdditionalItems}
|
||||||
>
|
>
|
||||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
</ha-generic-picker>
|
||||||
${this.hass.localize("ui.components.target-picker.add_target")}
|
|
||||||
</ha-button>
|
|
||||||
${!this._narrow && (this._pickerWrapperOpen || this._open)
|
|
||||||
? html`
|
|
||||||
<wa-popover
|
|
||||||
.open=${this._pickerWrapperOpen}
|
|
||||||
style="--body-width: ${this._addTargetWidth}px;"
|
|
||||||
without-arrow
|
|
||||||
distance="-4"
|
|
||||||
placement="bottom-start"
|
|
||||||
for="add-target-button"
|
|
||||||
auto-size="vertical"
|
|
||||||
auto-size-padding="16"
|
|
||||||
@wa-after-show=${this._showSelector}
|
|
||||||
@wa-after-hide=${this._hidePicker}
|
|
||||||
trap-focus
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label=${this.hass.localize(
|
|
||||||
"ui.components.target-picker.add_target"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
${this._renderTargetSelector()}
|
|
||||||
</wa-popover>
|
|
||||||
`
|
|
||||||
: this._pickerWrapperOpen || this._open
|
|
||||||
? html`<ha-bottom-sheet
|
|
||||||
flexcontent
|
|
||||||
.open=${this._pickerWrapperOpen}
|
|
||||||
@wa-after-show=${this._showSelector}
|
|
||||||
@closed=${this._hidePicker}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label=${this.hass.localize(
|
|
||||||
"ui.components.target-picker.add_target"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
${this._renderTargetSelector(true)}
|
|
||||||
</ha-bottom-sheet>`
|
|
||||||
: nothing}
|
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
|
||||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
|
||||||
>${this.helper}</ha-input-helper-text
|
|
||||||
>`
|
|
||||||
: nothing}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
private _targetPicked(ev: CustomEvent<{ value: string }>) {
|
||||||
super.connectedCallback();
|
|
||||||
this._handleResize();
|
|
||||||
window.addEventListener("resize", this._handleResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
public disconnectedCallback() {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
window.removeEventListener("resize", this._handleResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleResize = () => {
|
|
||||||
this._narrow =
|
|
||||||
window.matchMedia("(max-width: 870px)").matches ||
|
|
||||||
window.matchMedia("(max-height: 500px)").matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
private _showPicker() {
|
|
||||||
this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0;
|
|
||||||
this._pickerWrapperOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for drawer animation to finish
|
|
||||||
private _showSelector = () => {
|
|
||||||
this._open = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this._targetPickerSelectorElement?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private _handleUpdatePickerFilter(
|
|
||||||
ev: CustomEvent<TargetTypeFloorless | undefined>
|
|
||||||
) {
|
|
||||||
this._updatePickerFilter(
|
|
||||||
typeof ev.detail === "string" ? ev.detail : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
|
|
||||||
this._pickerFilter = filter;
|
|
||||||
};
|
|
||||||
|
|
||||||
private _hidePicker(ev) {
|
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this._open = false;
|
const value = ev.detail.value;
|
||||||
this._pickerWrapperOpen = false;
|
if (value.startsWith(CREATE_ID)) {
|
||||||
|
this._createNewDomainElement(value.substring(CREATE_ID.length));
|
||||||
if (this._newTarget) {
|
return;
|
||||||
this._addTarget(this._newTarget.id, this._newTarget.type);
|
|
||||||
this._newTarget = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private _renderTargetSelector(dialogMode = false) {
|
const [type, id] = ev.detail.value.split(SEPARATOR);
|
||||||
if (!this._open) {
|
this._addTarget(id, type as TargetType);
|
||||||
return nothing;
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<ha-target-picker-selector
|
|
||||||
.hass=${this.hass}
|
|
||||||
@filter-type-changed=${this._handleUpdatePickerFilter}
|
|
||||||
.filterType=${this._pickerFilter}
|
|
||||||
@target-picked=${this._handleTargetPicked}
|
|
||||||
@create-domain-picked=${this._handleCreateDomain}
|
|
||||||
.targetValue=${this.value}
|
|
||||||
.deviceFilter=${this.deviceFilter}
|
|
||||||
.entityFilter=${this.entityFilter}
|
|
||||||
.includeDomains=${this.includeDomains}
|
|
||||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
|
||||||
.createDomains=${this.createDomains}
|
|
||||||
.mode=${dialogMode ? "dialog" : "popover"}
|
|
||||||
></ha-target-picker-selector>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _addTarget(id: string, type: TargetType) {
|
private _addTarget(id: string, type: TargetType) {
|
||||||
@@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
?.removeAttribute("collapsed");
|
?.removeAttribute("collapsed");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleTargetPicked = async (
|
private _createNewDomainElement = (domain: string) => {
|
||||||
ev: CustomEvent<{ type: TargetType; id: string }>
|
|
||||||
) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
this._pickerWrapperOpen = false;
|
|
||||||
|
|
||||||
if (!ev.detail.type || !ev.detail.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// save new target temporarily to add it after dialog closes
|
|
||||||
this._newTarget = ev.detail;
|
|
||||||
};
|
|
||||||
|
|
||||||
private _handleCreateDomain = (ev: CustomEvent<string>) => {
|
|
||||||
this._pickerWrapperOpen = false;
|
|
||||||
|
|
||||||
const domain = ev.detail;
|
|
||||||
|
|
||||||
showHelperDetailDialog(this, {
|
showHelperDetailDialog(this, {
|
||||||
domain,
|
domain,
|
||||||
dialogClosedCallback: (item) => {
|
dialogClosedCallback: (item) => {
|
||||||
@@ -675,6 +634,459 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getRowType = (
|
||||||
|
item:
|
||||||
|
| PickerComboBoxItem
|
||||||
|
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||||
|
| EntityComboBoxItem
|
||||||
|
| DevicePickerItem
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
(item as FloorComboBoxItem).type === "area" ||
|
||||||
|
(item as FloorComboBoxItem).type === "floor"
|
||||||
|
) {
|
||||||
|
return (item as FloorComboBoxItem).type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("domain" in item) {
|
||||||
|
return "device";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("stateObj" in item) {
|
||||||
|
return "entity";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === EMPTY_SEARCH) {
|
||||||
|
return "empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "label";
|
||||||
|
};
|
||||||
|
|
||||||
|
private _sectionTitleFunction = ({
|
||||||
|
firstIndex,
|
||||||
|
lastIndex,
|
||||||
|
firstItem,
|
||||||
|
secondItem,
|
||||||
|
itemsCount,
|
||||||
|
}: {
|
||||||
|
firstIndex: number;
|
||||||
|
lastIndex: number;
|
||||||
|
firstItem: PickerComboBoxItem | string;
|
||||||
|
secondItem: PickerComboBoxItem | string;
|
||||||
|
itemsCount: number;
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
firstItem === undefined ||
|
||||||
|
secondItem === undefined ||
|
||||||
|
typeof firstItem === "string" ||
|
||||||
|
(typeof secondItem === "string" && secondItem !== "padding") ||
|
||||||
|
(firstIndex === 0 && lastIndex === itemsCount - 1)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = this._getRowType(firstItem as PickerComboBoxItem);
|
||||||
|
const translationType:
|
||||||
|
| "areas"
|
||||||
|
| "entities"
|
||||||
|
| "devices"
|
||||||
|
| "labels"
|
||||||
|
| undefined =
|
||||||
|
type === "area" || type === "floor"
|
||||||
|
? "areas"
|
||||||
|
: type === "entity"
|
||||||
|
? "entities"
|
||||||
|
: type && type !== "empty"
|
||||||
|
? `${type}s`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return translationType
|
||||||
|
? this.hass.localize(
|
||||||
|
`ui.components.target-picker.type.${translationType}`
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _getItems = (searchString: string, section: string) => {
|
||||||
|
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||||
|
|
||||||
|
return this._getItemsMemoized(
|
||||||
|
this.hass.localize,
|
||||||
|
this.entityFilter,
|
||||||
|
this.deviceFilter,
|
||||||
|
this.includeDomains,
|
||||||
|
this.includeDeviceClasses,
|
||||||
|
this.value,
|
||||||
|
searchString,
|
||||||
|
this._configEntryLookup,
|
||||||
|
this._selectedSection
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private _getItemsMemoized = memoizeOne(
|
||||||
|
(
|
||||||
|
localize: HomeAssistant["localize"],
|
||||||
|
entityFilter: this["entityFilter"],
|
||||||
|
deviceFilter: this["deviceFilter"],
|
||||||
|
includeDomains: this["includeDomains"],
|
||||||
|
includeDeviceClasses: this["includeDeviceClasses"],
|
||||||
|
targetValue: this["value"],
|
||||||
|
searchTerm: string,
|
||||||
|
configEntryLookup: Record<string, ConfigEntry>,
|
||||||
|
filterType?: TargetTypeFloorless
|
||||||
|
) => {
|
||||||
|
const items: (
|
||||||
|
| string
|
||||||
|
| FloorComboBoxItem
|
||||||
|
| EntityComboBoxItem
|
||||||
|
| PickerComboBoxItem
|
||||||
|
)[] = [];
|
||||||
|
|
||||||
|
if (!filterType || filterType === "entity") {
|
||||||
|
let entityItems = this._getEntitiesMemoized(
|
||||||
|
this.hass,
|
||||||
|
includeDomains,
|
||||||
|
undefined,
|
||||||
|
entityFilter,
|
||||||
|
includeDeviceClasses,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
targetValue?.entity_id
|
||||||
|
? ensureArray(targetValue.entity_id)
|
||||||
|
: undefined,
|
||||||
|
undefined,
|
||||||
|
`entity${SEPARATOR}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
entityItems = this._filterGroup(
|
||||||
|
"entity",
|
||||||
|
entityItems,
|
||||||
|
searchTerm,
|
||||||
|
(item: EntityComboBoxItem) =>
|
||||||
|
item.stateObj?.entity_id === searchTerm
|
||||||
|
) as EntityComboBoxItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType && entityItems.length) {
|
||||||
|
// show group title
|
||||||
|
items.push(localize("ui.components.target-picker.type.entities"));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(...entityItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType || filterType === "device") {
|
||||||
|
let deviceItems = this._getDevicesMemoized(
|
||||||
|
this.hass,
|
||||||
|
configEntryLookup,
|
||||||
|
includeDomains,
|
||||||
|
undefined,
|
||||||
|
includeDeviceClasses,
|
||||||
|
deviceFilter,
|
||||||
|
entityFilter,
|
||||||
|
targetValue?.device_id
|
||||||
|
? ensureArray(targetValue.device_id)
|
||||||
|
: undefined,
|
||||||
|
undefined,
|
||||||
|
`device${SEPARATOR}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType && deviceItems.length) {
|
||||||
|
// show group title
|
||||||
|
items.push(localize("ui.components.target-picker.type.devices"));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(...deviceItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType || filterType === "area") {
|
||||||
|
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||||||
|
this.hass.states,
|
||||||
|
this.hass.floors,
|
||||||
|
this.hass.areas,
|
||||||
|
this.hass.devices,
|
||||||
|
this.hass.entities,
|
||||||
|
memoizeOne((value: AreaFloorValue): string =>
|
||||||
|
[value.type, value.id].join(SEPARATOR)
|
||||||
|
),
|
||||||
|
includeDomains,
|
||||||
|
undefined,
|
||||||
|
includeDeviceClasses,
|
||||||
|
deviceFilter,
|
||||||
|
entityFilter,
|
||||||
|
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
|
||||||
|
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
areasAndFloors = this._filterGroup(
|
||||||
|
"area",
|
||||||
|
areasAndFloors,
|
||||||
|
searchTerm
|
||||||
|
) as FloorComboBoxItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType && areasAndFloors.length) {
|
||||||
|
// show group title
|
||||||
|
items.push(localize("ui.components.target-picker.type.areas"));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
...areasAndFloors.map((item, index) => {
|
||||||
|
const nextItem = areasAndFloors[index + 1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!nextItem ||
|
||||||
|
(item.type === "area" && nextItem.type === "floor")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
last: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType || filterType === "label") {
|
||||||
|
let labels = this._getLabelsMemoized(
|
||||||
|
this.hass,
|
||||||
|
this._labelRegistry,
|
||||||
|
includeDomains,
|
||||||
|
undefined,
|
||||||
|
includeDeviceClasses,
|
||||||
|
deviceFilter,
|
||||||
|
entityFilter,
|
||||||
|
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
|
||||||
|
`label${SEPARATOR}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
labels = this._filterGroup("label", labels, searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterType && labels.length) {
|
||||||
|
// show group title
|
||||||
|
items.push(localize("ui.components.target-picker.type.labels"));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(...labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _filterGroup(
|
||||||
|
type: TargetType,
|
||||||
|
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
|
||||||
|
searchTerm: string,
|
||||||
|
checkExact?: (
|
||||||
|
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
|
||||||
|
) => boolean
|
||||||
|
) {
|
||||||
|
const fuseIndex = this._fuseIndexes[type](items);
|
||||||
|
const fuse = new HaFuse(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
shouldSort: false,
|
||||||
|
minMatchCharLength: Math.min(searchTerm.length, 2),
|
||||||
|
},
|
||||||
|
fuseIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = fuse.multiTermsSearch(searchTerm);
|
||||||
|
let filteredItems = items;
|
||||||
|
if (results) {
|
||||||
|
filteredItems = results.map((result) => result.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkExact) {
|
||||||
|
return filteredItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is exact match for entity id, put it first
|
||||||
|
const index = filteredItems.findIndex((item) => checkExact(item));
|
||||||
|
if (index === -1) {
|
||||||
|
return filteredItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [exactMatch] = filteredItems.splice(index, 1);
|
||||||
|
filteredItems.unshift(exactMatch);
|
||||||
|
|
||||||
|
return filteredItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||||||
|
|
||||||
|
private _getCreateItems = memoizeOne(
|
||||||
|
(createDomains: this["createDomains"]) => {
|
||||||
|
if (!createDomains?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDomains.map((domain) => {
|
||||||
|
const primary = this.hass.localize(
|
||||||
|
"ui.components.entity.entity-picker.create_helper",
|
||||||
|
{
|
||||||
|
domain: isHelperDomain(domain)
|
||||||
|
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||||
|
: domainToName(this.hass.localize, domain),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: CREATE_ID + domain,
|
||||||
|
primary: primary,
|
||||||
|
secondary: this.hass.localize(
|
||||||
|
"ui.components.entity.entity-picker.new_entity"
|
||||||
|
),
|
||||||
|
icon_path: mdiPlus,
|
||||||
|
} satisfies EntityComboBoxItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private async _loadConfigEntries() {
|
||||||
|
const configEntries = await getConfigEntries(this.hass);
|
||||||
|
this._configEntryLookup = Object.fromEntries(
|
||||||
|
configEntries.map((entry) => [entry.entry_id, entry])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderRow = (
|
||||||
|
item:
|
||||||
|
| PickerComboBoxItem
|
||||||
|
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||||
|
| EntityComboBoxItem
|
||||||
|
| DevicePickerItem,
|
||||||
|
index: number
|
||||||
|
) => {
|
||||||
|
if (!item) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = this._getRowType(item);
|
||||||
|
let hasFloor = false;
|
||||||
|
let rtl = false;
|
||||||
|
let showEntityId = false;
|
||||||
|
|
||||||
|
if (type === "area" || type === "floor") {
|
||||||
|
item.id = item[type]?.[`${type}_id`];
|
||||||
|
|
||||||
|
rtl = computeRTL(this.hass);
|
||||||
|
hasFloor =
|
||||||
|
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "entity") {
|
||||||
|
showEntityId = !!this._showEntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-combo-box-item
|
||||||
|
id=${`list-item-${index}`}
|
||||||
|
tabindex="-1"
|
||||||
|
.type=${type === "empty" ? "text" : "button"}
|
||||||
|
class=${type === "empty" ? "empty" : ""}
|
||||||
|
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||||
|
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||||
|
? html`
|
||||||
|
<ha-tree-indicator
|
||||||
|
style=${styleMap({
|
||||||
|
width: "var(--ha-space-12)",
|
||||||
|
position: "absolute",
|
||||||
|
top: "var(--ha-space-0)",
|
||||||
|
left: rtl ? undefined : "var(--ha-space-1)",
|
||||||
|
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||||
|
transform: rtl ? "scaleX(-1)" : "",
|
||||||
|
})}
|
||||||
|
.end=${(
|
||||||
|
item as FloorComboBoxItem & { last?: boolean | undefined }
|
||||||
|
).last}
|
||||||
|
slot="start"
|
||||||
|
></ha-tree-indicator>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${item.icon
|
||||||
|
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||||
|
: item.icon_path
|
||||||
|
? html`<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${item.icon_path}
|
||||||
|
></ha-svg-icon>`
|
||||||
|
: type === "entity" && (item as EntityComboBoxItem).stateObj
|
||||||
|
? html`
|
||||||
|
<state-badge
|
||||||
|
slot="start"
|
||||||
|
.stateObj=${(item as EntityComboBoxItem).stateObj}
|
||||||
|
.hass=${this.hass}
|
||||||
|
></state-badge>
|
||||||
|
`
|
||||||
|
: type === "device" && (item as DevicePickerItem).domain
|
||||||
|
? html`
|
||||||
|
<img
|
||||||
|
slot="start"
|
||||||
|
alt=""
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
src=${brandsUrl({
|
||||||
|
domain: (item as DevicePickerItem).domain!,
|
||||||
|
type: "icon",
|
||||||
|
darkOptimized: this.hass.themes.darkMode,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
`
|
||||||
|
: type === "floor"
|
||||||
|
? html`<ha-floor-icon
|
||||||
|
slot="start"
|
||||||
|
.floor=${(item as FloorComboBoxItem).floor!}
|
||||||
|
></ha-floor-icon>`
|
||||||
|
: type === "area"
|
||||||
|
? html`<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${item.icon_path || mdiTextureBox}
|
||||||
|
></ha-svg-icon>`
|
||||||
|
: nothing}
|
||||||
|
<span slot="headline">${item.primary}</span>
|
||||||
|
${item.secondary
|
||||||
|
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||||
|
: nothing}
|
||||||
|
${(item as EntityComboBoxItem).stateObj && showEntityId
|
||||||
|
? html`
|
||||||
|
<span slot="supporting-text" class="code">
|
||||||
|
${(item as EntityComboBoxItem).stateObj?.entity_id}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${(item as EntityComboBoxItem).domain_name &&
|
||||||
|
(type !== "entity" || !showEntityId)
|
||||||
|
? html`
|
||||||
|
<div slot="trailing-supporting-text" class="domain">
|
||||||
|
${(item as EntityComboBoxItem).domain_name}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</ha-combo-box-item>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _noTargetFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.target-picker.no_target_found", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return css`
|
return css`
|
||||||
.add-target-wrapper {
|
.add-target-wrapper {
|
||||||
@@ -683,31 +1095,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
margin-top: var(--ha-space-3);
|
margin-top: var(--ha-space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
wa-popover {
|
ha-generic-picker {
|
||||||
--wa-space-l: var(--ha-space-0);
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
wa-popover::part(body) {
|
|
||||||
width: min(max(var(--body-width), 336px), 600px);
|
|
||||||
max-width: min(max(var(--body-width), 336px), 600px);
|
|
||||||
max-height: 500px;
|
|
||||||
height: 70vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-height: 1000px) {
|
|
||||||
wa-popover::part(body) {
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-bottom-sheet {
|
|
||||||
--ha-bottom-sheet-height: 90vh;
|
|
||||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
|
||||||
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
|
|
||||||
--ha-bottom-sheet-max-width: 600px;
|
|
||||||
--ha-bottom-sheet-padding: var(--ha-space-0);
|
|
||||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
${unsafeCSS(chipStyles)}
|
${unsafeCSS(chipStyles)}
|
||||||
|
|||||||
97
src/components/ha-trigger-icon.ts
Normal file
97
src/components/ha-trigger-icon.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
mdiAvTimer,
|
||||||
|
mdiCalendar,
|
||||||
|
mdiClockOutline,
|
||||||
|
mdiCodeBraces,
|
||||||
|
mdiDevices,
|
||||||
|
mdiFormatListBulleted,
|
||||||
|
mdiGestureDoubleTap,
|
||||||
|
mdiHomeAssistant,
|
||||||
|
mdiMapMarker,
|
||||||
|
mdiMapMarkerRadius,
|
||||||
|
mdiMessageAlert,
|
||||||
|
mdiMicrophoneMessage,
|
||||||
|
mdiNfcVariant,
|
||||||
|
mdiNumeric,
|
||||||
|
mdiStateMachine,
|
||||||
|
mdiSwapHorizontal,
|
||||||
|
mdiWeatherSunny,
|
||||||
|
mdiWebhook,
|
||||||
|
} 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 { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
|
||||||
|
import type { HomeAssistant } from "../types";
|
||||||
|
import "./ha-icon";
|
||||||
|
import "./ha-svg-icon";
|
||||||
|
|
||||||
|
export const TRIGGER_ICONS = {
|
||||||
|
calendar: mdiCalendar,
|
||||||
|
device: mdiDevices,
|
||||||
|
event: mdiGestureDoubleTap,
|
||||||
|
state: mdiStateMachine,
|
||||||
|
geo_location: mdiMapMarker,
|
||||||
|
homeassistant: mdiHomeAssistant,
|
||||||
|
mqtt: mdiSwapHorizontal,
|
||||||
|
numeric_state: mdiNumeric,
|
||||||
|
sun: mdiWeatherSunny,
|
||||||
|
conversation: mdiMicrophoneMessage,
|
||||||
|
tag: mdiNfcVariant,
|
||||||
|
template: mdiCodeBraces,
|
||||||
|
time: mdiClockOutline,
|
||||||
|
time_pattern: mdiAvTimer,
|
||||||
|
webhook: mdiWebhook,
|
||||||
|
persistent_notification: mdiMessageAlert,
|
||||||
|
zone: mdiMapMarkerRadius,
|
||||||
|
list: mdiFormatListBulleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement("ha-trigger-icon")
|
||||||
|
export class HaTriggerIcon extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property() public trigger?: string;
|
||||||
|
|
||||||
|
@property() public icon?: string;
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (this.icon) {
|
||||||
|
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.trigger) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hass) {
|
||||||
|
return this._renderFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = triggerIcon(this.hass, this.trigger).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.trigger!);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
|
||||||
|
></ha-svg-icon>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-trigger-icon": HaTriggerIcon;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -545,7 +545,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
|||||||
name: entityName || deviceName || item,
|
name: entityName || deviceName || item,
|
||||||
context,
|
context,
|
||||||
stateObject,
|
stateObject,
|
||||||
notFound: !stateObject && item !== "all",
|
notFound: !stateObject && item !== "all" && item !== "none",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -128,9 +128,7 @@ class HaUserPicker extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.user-picker.no_match"
|
|
||||||
)}
|
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
.getItems=${this._getItems}
|
.getItems=${this._getItems}
|
||||||
@@ -149,6 +147,11 @@ class HaUserPicker extends LitElement {
|
|||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.user-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
|||||||
{
|
{
|
||||||
groups: {
|
groups: {
|
||||||
device_id: {},
|
device_id: {},
|
||||||
serviceGroups: {},
|
dynamicGroups: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,14 +117,6 @@ export const VIRTUAL_ACTIONS: Partial<
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SERVICE_PREFIX = "__SERVICE__";
|
|
||||||
|
|
||||||
export const isService = (key: string | undefined): boolean | undefined =>
|
|
||||||
key?.startsWith(SERVICE_PREFIX);
|
|
||||||
|
|
||||||
export const getService = (key: string): string =>
|
|
||||||
key.substring(SERVICE_PREFIX.length);
|
|
||||||
|
|
||||||
export const COLLAPSIBLE_ACTION_ELEMENTS = [
|
export const COLLAPSIBLE_ACTION_ELEMENTS = [
|
||||||
"ha-automation-action-choose",
|
"ha-automation-action-choose",
|
||||||
"ha-automation-action-condition",
|
"ha-automation-action-condition",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
HassEntityAttributeBase,
|
HassEntityAttributeBase,
|
||||||
HassEntityBase,
|
HassEntityBase,
|
||||||
|
HassServiceTarget,
|
||||||
} from "home-assistant-js-websocket";
|
} from "home-assistant-js-websocket";
|
||||||
import { ensureArray } from "../common/array/ensure-array";
|
import { ensureArray } from "../common/array/ensure-array";
|
||||||
|
import type { WeekdayShort } from "../common/datetime/weekday";
|
||||||
import { navigate } from "../common/navigate";
|
import { navigate } from "../common/navigate";
|
||||||
import type { LocalizeKeys } from "../common/translations/localize";
|
import type { LocalizeKeys } from "../common/translations/localize";
|
||||||
import { createSearchParam } from "../common/url/search-params";
|
import { createSearchParam } from "../common/url/search-params";
|
||||||
@@ -12,10 +14,19 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
|
|||||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||||
import type { Action, Field, MODES } from "./script";
|
import type { Action, Field, MODES } from "./script";
|
||||||
import { migrateAutomationAction } from "./script";
|
import { migrateAutomationAction } from "./script";
|
||||||
|
import type { TriggerDescription } from "./trigger";
|
||||||
|
|
||||||
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
|
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
|
||||||
export const AUTOMATION_DEFAULT_MAX = 10;
|
export const AUTOMATION_DEFAULT_MAX = 10;
|
||||||
|
|
||||||
|
export const DYNAMIC_PREFIX = "__DYNAMIC__";
|
||||||
|
|
||||||
|
export const isDynamic = (key: string | undefined): boolean | undefined =>
|
||||||
|
key?.startsWith(DYNAMIC_PREFIX);
|
||||||
|
|
||||||
|
export const getValueFromDynamic = (key: string): string =>
|
||||||
|
key.substring(DYNAMIC_PREFIX.length);
|
||||||
|
|
||||||
export interface AutomationEntity extends HassEntityBase {
|
export interface AutomationEntity extends HassEntityBase {
|
||||||
attributes: HassEntityAttributeBase & {
|
attributes: HassEntityAttributeBase & {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -85,6 +96,12 @@ export interface BaseTrigger {
|
|||||||
id?: string;
|
id?: string;
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformTrigger extends BaseTrigger {
|
||||||
|
trigger: Exclude<string, LegacyTrigger["trigger"]>;
|
||||||
|
target?: HassServiceTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateTrigger extends BaseTrigger {
|
export interface StateTrigger extends BaseTrigger {
|
||||||
@@ -194,7 +211,7 @@ export interface CalendarTrigger extends BaseTrigger {
|
|||||||
offset: string;
|
offset: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Trigger =
|
export type LegacyTrigger =
|
||||||
| StateTrigger
|
| StateTrigger
|
||||||
| MqttTrigger
|
| MqttTrigger
|
||||||
| GeoLocationTrigger
|
| GeoLocationTrigger
|
||||||
@@ -211,8 +228,9 @@ export type Trigger =
|
|||||||
| TemplateTrigger
|
| TemplateTrigger
|
||||||
| EventTrigger
|
| EventTrigger
|
||||||
| DeviceTrigger
|
| DeviceTrigger
|
||||||
| CalendarTrigger
|
| CalendarTrigger;
|
||||||
| TriggerList;
|
|
||||||
|
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
|
||||||
|
|
||||||
interface BaseCondition {
|
interface BaseCondition {
|
||||||
condition: string;
|
condition: string;
|
||||||
@@ -257,13 +275,11 @@ export interface ZoneCondition extends BaseCondition {
|
|||||||
zone: string;
|
zone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
|
|
||||||
|
|
||||||
export interface TimeCondition extends BaseCondition {
|
export interface TimeCondition extends BaseCondition {
|
||||||
condition: "time";
|
condition: "time";
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
weekday?: Weekday | Weekday[];
|
weekday?: WeekdayShort | WeekdayShort[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateCondition extends BaseCondition {
|
export interface TemplateCondition extends BaseCondition {
|
||||||
@@ -576,6 +592,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
|||||||
insertAfter: (value: Trigger | Trigger[]) => boolean;
|
insertAfter: (value: Trigger | Trigger[]) => boolean;
|
||||||
toggleYamlMode: () => void;
|
toggleYamlMode: () => void;
|
||||||
config: Trigger;
|
config: Trigger;
|
||||||
|
description?: TriggerDescription;
|
||||||
yamlMode: boolean;
|
yamlMode: boolean;
|
||||||
uiSupported: boolean;
|
uiSupported: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
formatListWithAnds,
|
formatListWithAnds,
|
||||||
formatListWithOrs,
|
formatListWithOrs,
|
||||||
} from "../common/string/format-list";
|
} from "../common/string/format-list";
|
||||||
|
import { hasTemplate } from "../common/string/has-template";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
import type { Condition, ForDict, Trigger } from "./automation";
|
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
|
||||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||||
import {
|
import {
|
||||||
localizeDeviceAutomationCondition,
|
localizeDeviceAutomationCondition,
|
||||||
@@ -25,8 +26,7 @@ import {
|
|||||||
} from "./device_automation";
|
} from "./device_automation";
|
||||||
import type { EntityRegistryEntry } from "./entity_registry";
|
import type { EntityRegistryEntry } from "./entity_registry";
|
||||||
import type { FrontendLocaleData } from "./translation";
|
import type { FrontendLocaleData } from "./translation";
|
||||||
import { isTriggerList } from "./trigger";
|
import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger";
|
||||||
import { hasTemplate } from "../common/string/has-template";
|
|
||||||
|
|
||||||
const triggerTranslationBaseKey =
|
const triggerTranslationBaseKey =
|
||||||
"ui.panel.config.automation.editor.triggers.type";
|
"ui.panel.config.automation.editor.triggers.type";
|
||||||
@@ -121,6 +121,37 @@ const tryDescribeTrigger = (
|
|||||||
return trigger.alias;
|
return trigger.alias;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = describeLegacyTrigger(
|
||||||
|
trigger as LegacyTrigger,
|
||||||
|
hass,
|
||||||
|
entityRegistry
|
||||||
|
);
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerType = trigger.trigger;
|
||||||
|
|
||||||
|
const domain = getTriggerDomain(trigger.trigger);
|
||||||
|
const type = getTriggerObjectId(trigger.trigger);
|
||||||
|
|
||||||
|
return (
|
||||||
|
hass.localize(
|
||||||
|
`component.${domain}.triggers.${type}.description_configured`
|
||||||
|
) ||
|
||||||
|
hass.localize(
|
||||||
|
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
|
||||||
|
) ||
|
||||||
|
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeLegacyTrigger = (
|
||||||
|
trigger: LegacyTrigger,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entityRegistry: EntityRegistryEntry[]
|
||||||
|
) => {
|
||||||
// Event Trigger
|
// Event Trigger
|
||||||
if (trigger.trigger === "event" && trigger.event_type) {
|
if (trigger.trigger === "event" && trigger.event_type) {
|
||||||
const eventTypes: string[] = [];
|
const eventTypes: string[] = [];
|
||||||
@@ -802,13 +833,7 @@ const tryDescribeTrigger = (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
return (
|
|
||||||
hass.localize(
|
|
||||||
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
|
|
||||||
) ||
|
|
||||||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const describeCondition = (
|
export const describeCondition = (
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export const getDevices = (
|
|||||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||||
excludeDevices?: string[],
|
excludeDevices?: string[],
|
||||||
value?: string
|
value?: string,
|
||||||
|
idPrefix = ""
|
||||||
): DevicePickerItem[] => {
|
): DevicePickerItem[] => {
|
||||||
const devices = Object.values(hass.devices);
|
const devices = Object.values(hass.devices);
|
||||||
const entities = Object.values(hass.entities);
|
const entities = Object.values(hass.entities);
|
||||||
@@ -298,7 +299,7 @@ export const getDevices = (
|
|||||||
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
|
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: device.id,
|
id: `${idPrefix}${device.id}`,
|
||||||
label: "",
|
label: "",
|
||||||
primary:
|
primary:
|
||||||
deviceName ||
|
deviceName ||
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
|
|||||||
export interface DeviceConsumptionEnergyPreference {
|
export interface DeviceConsumptionEnergyPreference {
|
||||||
// This is an ever increasing value
|
// This is an ever increasing value
|
||||||
stat_consumption: string;
|
stat_consumption: string;
|
||||||
|
stat_rate?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
included_in_stat?: string;
|
included_in_stat?: string;
|
||||||
}
|
}
|
||||||
@@ -130,11 +131,17 @@ export interface FlowToGridSourceEnergyPreference {
|
|||||||
number_energy_price: number | null;
|
number_energy_price: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GridPowerSourceEnergyPreference {
|
||||||
|
// W meter
|
||||||
|
stat_rate: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GridSourceTypeEnergyPreference {
|
export interface GridSourceTypeEnergyPreference {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
|
|
||||||
flow_from: FlowFromGridSourceEnergyPreference[];
|
flow_from: FlowFromGridSourceEnergyPreference[];
|
||||||
flow_to: FlowToGridSourceEnergyPreference[];
|
flow_to: FlowToGridSourceEnergyPreference[];
|
||||||
|
power?: GridPowerSourceEnergyPreference[];
|
||||||
|
|
||||||
cost_adjustment_day: number;
|
cost_adjustment_day: number;
|
||||||
}
|
}
|
||||||
@@ -143,6 +150,7 @@ export interface SolarSourceTypeEnergyPreference {
|
|||||||
type: "solar";
|
type: "solar";
|
||||||
|
|
||||||
stat_energy_from: string;
|
stat_energy_from: string;
|
||||||
|
stat_rate?: string;
|
||||||
config_entry_solar_forecast: string[] | null;
|
config_entry_solar_forecast: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +158,7 @@ export interface BatterySourceTypeEnergyPreference {
|
|||||||
type: "battery";
|
type: "battery";
|
||||||
stat_energy_from: string;
|
stat_energy_from: string;
|
||||||
stat_energy_to: string;
|
stat_energy_to: string;
|
||||||
|
stat_rate?: string;
|
||||||
}
|
}
|
||||||
export interface GasSourceTypeEnergyPreference {
|
export interface GasSourceTypeEnergyPreference {
|
||||||
type: "gas";
|
type: "gas";
|
||||||
@@ -351,6 +360,35 @@ export const getReferencedStatisticIds = (
|
|||||||
return statIDs;
|
return statIDs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getReferencedStatisticIdsPower = (
|
||||||
|
prefs: EnergyPreferences
|
||||||
|
): string[] => {
|
||||||
|
const statIDs: (string | undefined)[] = [];
|
||||||
|
|
||||||
|
for (const source of prefs.energy_sources) {
|
||||||
|
if (source.type === "gas" || source.type === "water") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === "solar") {
|
||||||
|
statIDs.push(source.stat_rate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === "battery") {
|
||||||
|
statIDs.push(source.stat_rate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.power) {
|
||||||
|
statIDs.push(...source.power.map((p) => p.stat_rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||||
|
|
||||||
|
return statIDs.filter(Boolean) as string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const enum CompareMode {
|
export const enum CompareMode {
|
||||||
NONE = "",
|
NONE = "",
|
||||||
PREVIOUS = "previous",
|
PREVIOUS = "previous",
|
||||||
@@ -398,9 +436,10 @@ const getEnergyData = async (
|
|||||||
"gas",
|
"gas",
|
||||||
"device",
|
"device",
|
||||||
]);
|
]);
|
||||||
|
const powerStatIds = getReferencedStatisticIdsPower(prefs);
|
||||||
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
|
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
|
||||||
|
|
||||||
const allStatIDs = [...energyStatIds, ...waterStatIds];
|
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
|
||||||
|
|
||||||
const dayDifference = differenceInDays(end || new Date(), start);
|
const dayDifference = differenceInDays(end || new Date(), start);
|
||||||
const period =
|
const period =
|
||||||
@@ -411,6 +450,8 @@ const getEnergyData = async (
|
|||||||
: dayDifference > 2
|
: dayDifference > 2
|
||||||
? "day"
|
? "day"
|
||||||
: "hour";
|
: "hour";
|
||||||
|
const finePeriod =
|
||||||
|
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
|
||||||
|
|
||||||
const statsMetadata: Record<string, StatisticsMetaData> = {};
|
const statsMetadata: Record<string, StatisticsMetaData> = {};
|
||||||
const statsMetadataArray = allStatIDs.length
|
const statsMetadataArray = allStatIDs.length
|
||||||
@@ -432,6 +473,9 @@ const getEnergyData = async (
|
|||||||
? (gasUnit as (typeof VOLUME_UNITS)[number])
|
? (gasUnit as (typeof VOLUME_UNITS)[number])
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
const powerUnits: StatisticsUnitConfiguration = {
|
||||||
|
power: "kW",
|
||||||
|
};
|
||||||
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
|
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
|
||||||
const waterUnits: StatisticsUnitConfiguration = {
|
const waterUnits: StatisticsUnitConfiguration = {
|
||||||
volume: waterUnit,
|
volume: waterUnit,
|
||||||
@@ -442,6 +486,12 @@ const getEnergyData = async (
|
|||||||
"change",
|
"change",
|
||||||
])
|
])
|
||||||
: {};
|
: {};
|
||||||
|
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
|
||||||
|
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
|
||||||
|
"mean",
|
||||||
|
])
|
||||||
|
: {};
|
||||||
|
|
||||||
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
|
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
|
||||||
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
|
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
|
||||||
"change",
|
"change",
|
||||||
@@ -548,6 +598,7 @@ const getEnergyData = async (
|
|||||||
|
|
||||||
const [
|
const [
|
||||||
energyStats,
|
energyStats,
|
||||||
|
powerStats,
|
||||||
waterStats,
|
waterStats,
|
||||||
energyStatsCompare,
|
energyStatsCompare,
|
||||||
waterStatsCompare,
|
waterStatsCompare,
|
||||||
@@ -555,13 +606,14 @@ const getEnergyData = async (
|
|||||||
fossilEnergyConsumptionCompare,
|
fossilEnergyConsumptionCompare,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
_energyStats,
|
_energyStats,
|
||||||
|
_powerStats,
|
||||||
_waterStats,
|
_waterStats,
|
||||||
_energyStatsCompare,
|
_energyStatsCompare,
|
||||||
_waterStatsCompare,
|
_waterStatsCompare,
|
||||||
_fossilEnergyConsumption,
|
_fossilEnergyConsumption,
|
||||||
_fossilEnergyConsumptionCompare,
|
_fossilEnergyConsumptionCompare,
|
||||||
]);
|
]);
|
||||||
const stats = { ...energyStats, ...waterStats };
|
const stats = { ...energyStats, ...waterStats, ...powerStats };
|
||||||
if (compare) {
|
if (compare) {
|
||||||
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
|
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,8 @@ export const getEntities = (
|
|||||||
includeUnitOfMeasurement?: string[],
|
includeUnitOfMeasurement?: string[],
|
||||||
includeEntities?: string[],
|
includeEntities?: string[],
|
||||||
excludeEntities?: string[],
|
excludeEntities?: string[],
|
||||||
value?: string
|
value?: string,
|
||||||
|
idPrefix = ""
|
||||||
): EntityComboBoxItem[] => {
|
): EntityComboBoxItem[] => {
|
||||||
let items: EntityComboBoxItem[] = [];
|
let items: EntityComboBoxItem[] = [];
|
||||||
|
|
||||||
@@ -395,10 +396,9 @@ export const getEntities = (
|
|||||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(isRTL ? " ◂ " : " ▸ ");
|
.join(isRTL ? " ◂ " : " ▸ ");
|
||||||
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entityId,
|
id: `${idPrefix}${entityId}`,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
secondary: secondary,
|
secondary: secondary,
|
||||||
domain_name: domainName,
|
domain_name: domainName,
|
||||||
@@ -411,7 +411,6 @@ export const getEntities = (
|
|||||||
friendlyName,
|
friendlyName,
|
||||||
entityId,
|
entityId,
|
||||||
].filter(Boolean) as string[],
|
].filter(Boolean) as string[],
|
||||||
a11y_label: a11yLabel,
|
|
||||||
stateObj: stateObj,
|
stateObj: stateObj,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
} from "./entity_registry";
|
} from "./entity_registry";
|
||||||
|
|
||||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||||
|
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
|
||||||
|
|
||||||
/** Icon to use when no icon specified for service. */
|
/** Icon to use when no icon specified for service. */
|
||||||
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
||||||
@@ -133,14 +134,19 @@ const resources: {
|
|||||||
all?: Promise<Record<string, ServiceIcons>>;
|
all?: Promise<Record<string, ServiceIcons>>;
|
||||||
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
|
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
|
||||||
};
|
};
|
||||||
|
triggers: {
|
||||||
|
all?: Promise<Record<string, TriggerIcons>>;
|
||||||
|
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
|
||||||
|
};
|
||||||
} = {
|
} = {
|
||||||
entity: {},
|
entity: {},
|
||||||
entity_component: {},
|
entity_component: {},
|
||||||
services: { domains: {} },
|
services: { domains: {} },
|
||||||
|
triggers: { domains: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IconResources<
|
interface IconResources<
|
||||||
T extends ComponentIcons | PlatformIcons | ServiceIcons,
|
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
|
||||||
> {
|
> {
|
||||||
resources: Record<string, T>;
|
resources: Record<string, T>;
|
||||||
}
|
}
|
||||||
@@ -184,12 +190,22 @@ type ServiceIcons = Record<
|
|||||||
{ service: string; sections?: Record<string, string> }
|
{ service: string; sections?: Record<string, string> }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type IconCategory = "entity" | "entity_component" | "services";
|
type TriggerIcons = Record<
|
||||||
|
string,
|
||||||
|
{ trigger: string; sections?: Record<string, string> }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type IconCategory =
|
||||||
|
| "entity"
|
||||||
|
| "entity_component"
|
||||||
|
| "services"
|
||||||
|
| "triggers";
|
||||||
|
|
||||||
interface CategoryType {
|
interface CategoryType {
|
||||||
entity: PlatformIcons;
|
entity: PlatformIcons;
|
||||||
entity_component: ComponentIcons;
|
entity_component: ComponentIcons;
|
||||||
services: ServiceIcons;
|
services: ServiceIcons;
|
||||||
|
triggers: TriggerIcons;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHassIcons = async <T extends IconCategory>(
|
export const getHassIcons = async <T extends IconCategory>(
|
||||||
@@ -258,42 +274,59 @@ export const getComponentIcons = async (
|
|||||||
return resources.entity_component.resources.then((res) => res[domain]);
|
return resources.entity_component.resources.then((res) => res[domain]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServiceIcons = async (
|
export const getCategoryIcons = async <
|
||||||
|
T extends Exclude<IconCategory, "entity" | "entity_component">,
|
||||||
|
>(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
category: T,
|
||||||
domain?: string,
|
domain?: string,
|
||||||
force = false
|
force = false
|
||||||
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => {
|
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
|
||||||
if (!domain) {
|
if (!domain) {
|
||||||
if (!force && resources.services.all) {
|
if (!force && resources[category].all) {
|
||||||
return resources.services.all;
|
return resources[category].all as Promise<
|
||||||
|
Record<string, CategoryType[T]>
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
resources.services.all = getHassIcons(hass, "services", domain).then(
|
resources[category].all = getHassIcons(hass, category).then((res) => {
|
||||||
(res) => {
|
resources[category].domains = res.resources as any;
|
||||||
resources.services.domains = res.resources;
|
return res?.resources as Record<string, CategoryType[T]>;
|
||||||
return res?.resources;
|
}) as any;
|
||||||
}
|
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
|
||||||
);
|
|
||||||
return resources.services.all;
|
|
||||||
}
|
}
|
||||||
if (!force && domain in resources.services.domains) {
|
if (!force && domain in resources[category].domains) {
|
||||||
return resources.services.domains[domain];
|
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||||
}
|
}
|
||||||
if (resources.services.all && !force) {
|
if (resources[category].all && !force) {
|
||||||
await resources.services.all;
|
await resources[category].all;
|
||||||
if (domain in resources.services.domains) {
|
if (domain in resources[category].domains) {
|
||||||
return resources.services.domains[domain];
|
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isComponentLoaded(hass, domain)) {
|
if (!isComponentLoaded(hass, domain)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const result = getHassIcons(hass, "services", domain);
|
const result = getHassIcons(hass, category, domain);
|
||||||
resources.services.domains[domain] = result.then(
|
resources[category].domains[domain] = result.then(
|
||||||
(res) => res?.resources[domain]
|
(res) => res?.resources[domain]
|
||||||
);
|
) as any;
|
||||||
return resources.services.domains[domain];
|
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getServiceIcons = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain?: string,
|
||||||
|
force = false
|
||||||
|
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
|
||||||
|
getCategoryIcons(hass, "services", domain, force);
|
||||||
|
|
||||||
|
export const getTriggerIcons = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain?: string,
|
||||||
|
force = false
|
||||||
|
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
|
||||||
|
getCategoryIcons(hass, "triggers", domain, force);
|
||||||
|
|
||||||
// Cache for sorted range keys
|
// Cache for sorted range keys
|
||||||
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
|
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
|
||||||
|
|
||||||
@@ -473,6 +506,26 @@ export const attributeIcon = async (
|
|||||||
return icon;
|
return icon;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const triggerIcon = async (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
trigger: string
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
let icon: string | undefined;
|
||||||
|
|
||||||
|
const domain = getTriggerDomain(trigger);
|
||||||
|
const triggerName = getTriggerObjectId(trigger);
|
||||||
|
|
||||||
|
const triggerIcons = await getTriggerIcons(hass, domain);
|
||||||
|
if (triggerIcons) {
|
||||||
|
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
|
||||||
|
icon = trgrIcon?.trigger;
|
||||||
|
}
|
||||||
|
if (!icon) {
|
||||||
|
icon = await domainIcon(hass, domain);
|
||||||
|
}
|
||||||
|
return icon;
|
||||||
|
};
|
||||||
|
|
||||||
export const serviceIcon = async (
|
export const serviceIcon = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
service: string
|
service: string
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export const getLabels = (
|
|||||||
includeDeviceClasses?: string[],
|
includeDeviceClasses?: string[],
|
||||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||||
excludeLabels?: string[]
|
excludeLabels?: string[],
|
||||||
|
idPrefix = ""
|
||||||
): PickerComboBoxItem[] => {
|
): PickerComboBoxItem[] => {
|
||||||
if (!labels || labels.length === 0) {
|
if (!labels || labels.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -262,7 +263,7 @@ export const getLabels = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
|
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
|
||||||
id: label.label_id,
|
id: `${idPrefix}${label.label_id}`,
|
||||||
primary: label.name,
|
primary: label.name,
|
||||||
secondary: label.description ?? "",
|
secondary: label.description ?? "",
|
||||||
icon: label.icon || undefined,
|
icon: label.icon || undefined,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface TodoItem {
|
|||||||
status: TodoItemStatus | null;
|
status: TodoItemStatus | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
due?: string | null;
|
due?: string | null;
|
||||||
|
completed?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum TodoListEntityFeature {
|
export const enum TodoListEntityFeature {
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export type TranslationCategory =
|
|||||||
| "application_credentials"
|
| "application_credentials"
|
||||||
| "issues"
|
| "issues"
|
||||||
| "selector"
|
| "selector"
|
||||||
| "services";
|
| "services"
|
||||||
|
| "triggers";
|
||||||
|
|
||||||
export const subscribeTranslationPreferences = (
|
export const subscribeTranslationPreferences = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|||||||
@@ -1,57 +1,20 @@
|
|||||||
import {
|
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||||
mdiAvTimer,
|
|
||||||
mdiCalendar,
|
|
||||||
mdiClockOutline,
|
|
||||||
mdiCodeBraces,
|
|
||||||
mdiDevices,
|
|
||||||
mdiFormatListBulleted,
|
|
||||||
mdiGestureDoubleTap,
|
|
||||||
mdiMapClock,
|
|
||||||
mdiMapMarker,
|
|
||||||
mdiMapMarkerRadius,
|
|
||||||
mdiMessageAlert,
|
|
||||||
mdiMicrophoneMessage,
|
|
||||||
mdiNfcVariant,
|
|
||||||
mdiNumeric,
|
|
||||||
mdiShape,
|
|
||||||
mdiStateMachine,
|
|
||||||
mdiSwapHorizontal,
|
|
||||||
mdiWeatherSunny,
|
|
||||||
mdiWebhook,
|
|
||||||
} from "@mdi/js";
|
|
||||||
|
|
||||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
import { computeDomain } from "../common/entity/compute_domain";
|
||||||
|
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||||
|
import type { HomeAssistant } from "../types";
|
||||||
import type {
|
import type {
|
||||||
AutomationElementGroupCollection,
|
AutomationElementGroupCollection,
|
||||||
Trigger,
|
Trigger,
|
||||||
TriggerList,
|
TriggerList,
|
||||||
} from "./automation";
|
} from "./automation";
|
||||||
|
import type { Selector, TargetSelector } from "./selector";
|
||||||
export const TRIGGER_ICONS = {
|
|
||||||
calendar: mdiCalendar,
|
|
||||||
device: mdiDevices,
|
|
||||||
event: mdiGestureDoubleTap,
|
|
||||||
state: mdiStateMachine,
|
|
||||||
geo_location: mdiMapMarker,
|
|
||||||
homeassistant: mdiHomeAssistant,
|
|
||||||
mqtt: mdiSwapHorizontal,
|
|
||||||
numeric_state: mdiNumeric,
|
|
||||||
sun: mdiWeatherSunny,
|
|
||||||
conversation: mdiMicrophoneMessage,
|
|
||||||
tag: mdiNfcVariant,
|
|
||||||
template: mdiCodeBraces,
|
|
||||||
time: mdiClockOutline,
|
|
||||||
time_pattern: mdiAvTimer,
|
|
||||||
webhook: mdiWebhook,
|
|
||||||
persistent_notification: mdiMessageAlert,
|
|
||||||
zone: mdiMapMarkerRadius,
|
|
||||||
list: mdiFormatListBulleted,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||||
{
|
{
|
||||||
groups: {
|
groups: {
|
||||||
device: {},
|
device: {},
|
||||||
|
dynamicGroups: {},
|
||||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||||
time_location: {
|
time_location: {
|
||||||
icon: mdiMapClock,
|
icon: mdiMapClock,
|
||||||
@@ -83,3 +46,33 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
|||||||
|
|
||||||
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
|
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
|
||||||
"triggers" in trigger;
|
"triggers" in trigger;
|
||||||
|
|
||||||
|
export interface TriggerDescription {
|
||||||
|
target?: TargetSelector["target"];
|
||||||
|
fields: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
example?: string | boolean | number;
|
||||||
|
default?: unknown;
|
||||||
|
required?: boolean;
|
||||||
|
selector?: Selector;
|
||||||
|
context?: Record<string, string>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerDescriptions = Record<string, TriggerDescription>;
|
||||||
|
|
||||||
|
export const subscribeTriggers = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
callback: (triggers: TriggerDescriptions) => void
|
||||||
|
) =>
|
||||||
|
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
|
||||||
|
type: "trigger_platforms/subscribe",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getTriggerDomain = (trigger: string) =>
|
||||||
|
trigger.includes(".") ? computeDomain(trigger) : trigger;
|
||||||
|
|
||||||
|
export const getTriggerObjectId = (trigger: string) =>
|
||||||
|
trigger.includes(".") ? computeObjectId(trigger) : "_";
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class DialogEditSidebar extends LitElement {
|
|||||||
// Add default hidden panels that are missing in hidden
|
// Add default hidden panels that are missing in hidden
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
if (
|
if (
|
||||||
!panel.default_visible &&
|
panel.default_visible === false &&
|
||||||
!this._order.includes(panel.url_path) &&
|
!this._order.includes(panel.url_path) &&
|
||||||
!this._hidden.includes(panel.url_path)
|
!this._hidden.includes(panel.url_path)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -20,23 +20,44 @@
|
|||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<%= renderTemplate("_style_base.html.template") %>
|
<%= renderTemplate("_style_base.html.template") %>
|
||||||
<style>
|
<style>
|
||||||
|
@keyframes fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
::view-transition-group(launch-screen) {
|
||||||
|
animation-duration: var(--ha-animation-base-duration, 350ms);
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
::view-transition-old(launch-screen) {
|
||||||
|
animation: fade-out var(--ha-animation-base-duration, 350ms) ease-out;
|
||||||
|
}
|
||||||
html {
|
html {
|
||||||
background-color: var(--primary-background-color, #fafafa);
|
background-color: var(--primary-background-color, #fafafa);
|
||||||
color: var(--primary-text-color, #212121);
|
color: var(--primary-text-color, #212121);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
html {
|
|
||||||
background-color: var(--primary-background-color, #111111);
|
|
||||||
color: var(--primary-text-color, #e1e1e1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#ha-launch-screen {
|
#ha-launch-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
view-transition-name: launch-screen;
|
||||||
|
background-color: var(--primary-background-color, #fafafa);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
#ha-launch-screen.removing {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
#ha-launch-screen svg {
|
#ha-launch-screen svg {
|
||||||
width: 112px;
|
width: 112px;
|
||||||
@@ -59,6 +80,14 @@
|
|||||||
opacity: .66;
|
opacity: .66;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
background-color: var(--primary-background-color, #111111);
|
||||||
|
color: var(--primary-text-color, #e1e1e1);
|
||||||
|
}
|
||||||
|
/* body selector to avoid minification causing bad jinja2 */
|
||||||
|
body #ha-launch-screen {
|
||||||
|
background-color: var(--primary-background-color, #111111);
|
||||||
|
}
|
||||||
.ohf-logo {
|
.ohf-logo {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const COMPONENTS = {
|
|||||||
light: () => import("../panels/light/ha-panel-light"),
|
light: () => import("../panels/light/ha-panel-light"),
|
||||||
security: () => import("../panels/security/ha-panel-security"),
|
security: () => import("../panels/security/ha-panel-security"),
|
||||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||||
|
home: () => import("../panels/home/ha-panel-home"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@customElement("partial-panel-resolver")
|
@customElement("partial-panel-resolver")
|
||||||
|
|||||||
@@ -1,82 +1,56 @@
|
|||||||
import type { ReactiveElement } from "lit";
|
import type { ReactiveElement } from "lit";
|
||||||
import { listenMediaQuery } from "../common/dom/media_query";
|
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
|
import {
|
||||||
|
setupMediaQueryListeners,
|
||||||
|
setupTimeListeners,
|
||||||
|
} from "../common/condition/listeners";
|
||||||
import type { Condition } from "../panels/lovelace/common/validate-condition";
|
import type { Condition } from "../panels/lovelace/common/validate-condition";
|
||||||
import { checkConditionsMet } from "../panels/lovelace/common/validate-condition";
|
|
||||||
|
|
||||||
type Constructor<T> = abstract new (...args: any[]) => T;
|
type Constructor<T> = abstract new (...args: any[]) => T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract media queries from conditions recursively
|
* Base config type that can be used with conditional listeners
|
||||||
*/
|
*/
|
||||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
export interface ConditionalConfig {
|
||||||
return conditions.reduce<string[]>((array, c) => {
|
visibility?: Condition[];
|
||||||
if ("conditions" in c && c.conditions) {
|
[key: string]: any;
|
||||||
array.push(...extractMediaQueries(c.conditions));
|
|
||||||
}
|
|
||||||
if (c.condition === "screen" && c.media_query) {
|
|
||||||
array.push(c.media_query);
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to setup media query listeners for conditional visibility
|
|
||||||
*/
|
|
||||||
export function setupMediaQueryListeners(
|
|
||||||
conditions: Condition[],
|
|
||||||
hass: HomeAssistant,
|
|
||||||
addListener: (unsub: () => void) => void,
|
|
||||||
onUpdate: (conditionsMet: boolean) => void
|
|
||||||
): void {
|
|
||||||
const mediaQueries = extractMediaQueries(conditions);
|
|
||||||
|
|
||||||
if (mediaQueries.length === 0) return;
|
|
||||||
|
|
||||||
// Optimization for single media query
|
|
||||||
const hasOnlyMediaQuery =
|
|
||||||
conditions.length === 1 &&
|
|
||||||
conditions[0].condition === "screen" &&
|
|
||||||
!!conditions[0].media_query;
|
|
||||||
|
|
||||||
mediaQueries.forEach((mediaQuery) => {
|
|
||||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
|
||||||
if (hasOnlyMediaQuery) {
|
|
||||||
onUpdate(matches);
|
|
||||||
} else {
|
|
||||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
|
||||||
onUpdate(conditionsMet);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
addListener(unsub);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mixin to handle conditional listeners for visibility control
|
* Mixin to handle conditional listeners for visibility control
|
||||||
*
|
*
|
||||||
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
|
* Provides lifecycle management for listeners that control conditional
|
||||||
* that control conditional visibility of components.
|
* visibility of components.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* 1. Extend your component with ConditionalListenerMixin(ReactiveElement)
|
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
|
||||||
* 2. Override setupConditionalListeners() to setup your listeners
|
* 2. Ensure component has config.visibility or _config.visibility property with conditions
|
||||||
* 3. Use addConditionalListener() to register unsubscribe functions
|
* 3. Ensure component has _updateVisibility() or _updateElement() method
|
||||||
* 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes
|
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
|
||||||
*
|
*
|
||||||
* The mixin automatically:
|
* The mixin automatically:
|
||||||
* - Sets up listeners when component connects to DOM
|
* - Sets up listeners when component connects to DOM
|
||||||
* - Cleans up listeners when component disconnects from DOM
|
* - Cleans up listeners when component disconnects from DOM
|
||||||
|
* - Handles conditional visibility based on defined conditions
|
||||||
*/
|
*/
|
||||||
export const ConditionalListenerMixin = <
|
export const ConditionalListenerMixin = <
|
||||||
T extends Constructor<ReactiveElement>,
|
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||||
>(
|
>(
|
||||||
superClass: T
|
superClass: Constructor<ReactiveElement>
|
||||||
) => {
|
) => {
|
||||||
abstract class ConditionalListenerClass extends superClass {
|
abstract class ConditionalListenerClass extends superClass {
|
||||||
private __listeners: (() => void)[] = [];
|
private __listeners: (() => void)[] = [];
|
||||||
|
|
||||||
|
protected _config?: TConfig;
|
||||||
|
|
||||||
|
public config?: TConfig;
|
||||||
|
|
||||||
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
protected _updateElement?(config: TConfig): void;
|
||||||
|
|
||||||
|
protected _updateVisibility?(conditionsMet?: boolean): void;
|
||||||
|
|
||||||
public connectedCallback() {
|
public connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.setupConditionalListeners();
|
this.setupConditionalListeners();
|
||||||
@@ -87,17 +61,72 @@ export const ConditionalListenerMixin = <
|
|||||||
this.clearConditionalListeners();
|
this.clearConditionalListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear conditional listeners
|
||||||
|
*
|
||||||
|
* This method is called when the component is disconnected from the DOM.
|
||||||
|
* It clears all the listeners that were set up by the setupConditionalListeners() method.
|
||||||
|
*/
|
||||||
protected clearConditionalListeners(): void {
|
protected clearConditionalListeners(): void {
|
||||||
this.__listeners.forEach((unsub) => unsub());
|
this.__listeners.forEach((unsub) => unsub());
|
||||||
this.__listeners = [];
|
this.__listeners = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a conditional listener to the list of listeners
|
||||||
|
*
|
||||||
|
* This method is called when a new listener is added.
|
||||||
|
* It adds the listener to the list of listeners.
|
||||||
|
*
|
||||||
|
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
protected addConditionalListener(unsubscribe: () => void): void {
|
protected addConditionalListener(unsubscribe: () => void): void {
|
||||||
this.__listeners.push(unsubscribe);
|
this.__listeners.push(unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupConditionalListeners(): void {
|
/**
|
||||||
// Override in subclass
|
* Setup conditional listeners for visibility control
|
||||||
|
*
|
||||||
|
* Default implementation:
|
||||||
|
* - Checks config.visibility or _config.visibility for conditions (if not provided)
|
||||||
|
* - Sets up appropriate listeners based on condition types
|
||||||
|
* - Calls _updateVisibility() or _updateElement() when conditions change
|
||||||
|
*
|
||||||
|
* Override this method to customize behavior (e.g., filter conditions first)
|
||||||
|
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
|
||||||
|
*
|
||||||
|
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
|
||||||
|
*/
|
||||||
|
protected setupConditionalListeners(conditions?: Condition[]): void {
|
||||||
|
const config = this.config || this._config;
|
||||||
|
const finalConditions = conditions || config?.visibility;
|
||||||
|
|
||||||
|
if (!finalConditions || !this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdate = (conditionsMet: boolean) => {
|
||||||
|
if (this._updateVisibility) {
|
||||||
|
this._updateVisibility(conditionsMet);
|
||||||
|
} else if (this._updateElement && config) {
|
||||||
|
this._updateElement(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupMediaQueryListeners(
|
||||||
|
finalConditions,
|
||||||
|
this.hass,
|
||||||
|
(unsub) => this.addConditionalListener(unsub),
|
||||||
|
onUpdate
|
||||||
|
);
|
||||||
|
|
||||||
|
setupTimeListeners(
|
||||||
|
finalConditions,
|
||||||
|
this.hass,
|
||||||
|
(unsub) => this.addConditionalListener(unsub),
|
||||||
|
onUpdate
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ConditionalListenerClass;
|
return ConditionalListenerClass;
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export class HaConfigApplicationCredentials extends LitElement {
|
|||||||
),
|
),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
direction: "asc",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
title: "",
|
title: "",
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import "../../../../components/ha-sortable";
|
|||||||
import "../../../../components/ha-svg-icon";
|
import "../../../../components/ha-svg-icon";
|
||||||
import {
|
import {
|
||||||
ACTION_BUILDING_BLOCKS,
|
ACTION_BUILDING_BLOCKS,
|
||||||
getService,
|
|
||||||
isService,
|
|
||||||
VIRTUAL_ACTIONS,
|
VIRTUAL_ACTIONS,
|
||||||
} from "../../../../data/action";
|
} from "../../../../data/action";
|
||||||
import type { AutomationClipboard } from "../../../../data/automation";
|
import {
|
||||||
|
getValueFromDynamic,
|
||||||
|
isDynamic,
|
||||||
|
type AutomationClipboard,
|
||||||
|
} from "../../../../data/automation";
|
||||||
import type { Action } from "../../../../data/script";
|
import type { Action } from "../../../../data/script";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import {
|
import {
|
||||||
@@ -217,9 +219,9 @@ export default class HaAutomationAction extends LitElement {
|
|||||||
actions = this.actions.concat(deepClone(this._clipboard!.action));
|
actions = this.actions.concat(deepClone(this._clipboard!.action));
|
||||||
} else if (action in VIRTUAL_ACTIONS) {
|
} else if (action in VIRTUAL_ACTIONS) {
|
||||||
actions = this.actions.concat(VIRTUAL_ACTIONS[action]);
|
actions = this.actions.concat(VIRTUAL_ACTIONS[action]);
|
||||||
} else if (isService(action)) {
|
} else if (isDynamic(action)) {
|
||||||
actions = this.actions.concat({
|
actions = this.actions.concat({
|
||||||
action: getService(action),
|
action: getValueFromDynamic(action),
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
mdiPlus,
|
mdiPlus,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
|
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||||
import { LitElement, css, html, nothing } from "lit";
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
import {
|
import {
|
||||||
@@ -40,32 +41,39 @@ import "../../../components/ha-md-list";
|
|||||||
import type { HaMdList } from "../../../components/ha-md-list";
|
import type { HaMdList } from "../../../components/ha-md-list";
|
||||||
import "../../../components/ha-md-list-item";
|
import "../../../components/ha-md-list-item";
|
||||||
import "../../../components/ha-service-icon";
|
import "../../../components/ha-service-icon";
|
||||||
|
import { TRIGGER_ICONS } from "../../../components/ha-trigger-icon";
|
||||||
import "../../../components/ha-wa-dialog";
|
import "../../../components/ha-wa-dialog";
|
||||||
import "../../../components/search-input";
|
import "../../../components/search-input";
|
||||||
import {
|
import {
|
||||||
ACTION_BUILDING_BLOCKS_GROUP,
|
ACTION_BUILDING_BLOCKS_GROUP,
|
||||||
ACTION_COLLECTIONS,
|
ACTION_COLLECTIONS,
|
||||||
ACTION_ICONS,
|
ACTION_ICONS,
|
||||||
SERVICE_PREFIX,
|
|
||||||
getService,
|
|
||||||
isService,
|
|
||||||
} from "../../../data/action";
|
} from "../../../data/action";
|
||||||
import type {
|
import {
|
||||||
AutomationElementGroup,
|
DYNAMIC_PREFIX,
|
||||||
AutomationElementGroupCollection,
|
getValueFromDynamic,
|
||||||
|
isDynamic,
|
||||||
|
type AutomationElementGroup,
|
||||||
|
type AutomationElementGroupCollection,
|
||||||
} from "../../../data/automation";
|
} from "../../../data/automation";
|
||||||
import {
|
import {
|
||||||
CONDITION_BUILDING_BLOCKS_GROUP,
|
CONDITION_BUILDING_BLOCKS_GROUP,
|
||||||
CONDITION_COLLECTIONS,
|
CONDITION_COLLECTIONS,
|
||||||
CONDITION_ICONS,
|
CONDITION_ICONS,
|
||||||
} from "../../../data/condition";
|
} from "../../../data/condition";
|
||||||
import { getServiceIcons } from "../../../data/icons";
|
import { getServiceIcons, getTriggerIcons } from "../../../data/icons";
|
||||||
import type { IntegrationManifest } from "../../../data/integration";
|
import type { IntegrationManifest } from "../../../data/integration";
|
||||||
import {
|
import {
|
||||||
domainToName,
|
domainToName,
|
||||||
fetchIntegrationManifests,
|
fetchIntegrationManifests,
|
||||||
} from "../../../data/integration";
|
} from "../../../data/integration";
|
||||||
import { TRIGGER_COLLECTIONS, TRIGGER_ICONS } from "../../../data/trigger";
|
import type { TriggerDescriptions } from "../../../data/trigger";
|
||||||
|
import {
|
||||||
|
TRIGGER_COLLECTIONS,
|
||||||
|
getTriggerDomain,
|
||||||
|
getTriggerObjectId,
|
||||||
|
subscribeTriggers,
|
||||||
|
} from "../../../data/trigger";
|
||||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||||
import { HaFuse } from "../../../resources/fuse";
|
import { HaFuse } from "../../../resources/fuse";
|
||||||
@@ -111,7 +119,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
|
|||||||
|
|
||||||
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
|
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
|
||||||
|
|
||||||
const ACTION_SERVICE_KEYWORDS = ["serviceGroups", "helpers", "other"];
|
const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"];
|
||||||
|
|
||||||
@customElement("add-automation-element-dialog")
|
@customElement("add-automation-element-dialog")
|
||||||
class DialogAddAutomationElement
|
class DialogAddAutomationElement
|
||||||
@@ -142,6 +150,8 @@ class DialogAddAutomationElement
|
|||||||
|
|
||||||
@state() private _narrow = false;
|
@state() private _narrow = false;
|
||||||
|
|
||||||
|
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||||
|
|
||||||
@query(".items ha-md-list ha-md-list-item")
|
@query(".items ha-md-list ha-md-list-item")
|
||||||
private _itemsListFirstElement?: HaMdList;
|
private _itemsListFirstElement?: HaMdList;
|
||||||
|
|
||||||
@@ -152,6 +162,8 @@ class DialogAddAutomationElement
|
|||||||
|
|
||||||
private _removeKeyboardShortcuts?: () => void;
|
private _removeKeyboardShortcuts?: () => void;
|
||||||
|
|
||||||
|
private _unsub?: Promise<UnsubscribeFunc>;
|
||||||
|
|
||||||
public showDialog(params): void {
|
public showDialog(params): void {
|
||||||
this._params = params;
|
this._params = params;
|
||||||
|
|
||||||
@@ -163,6 +175,17 @@ class DialogAddAutomationElement
|
|||||||
this._calculateUsedDomains();
|
this._calculateUsedDomains();
|
||||||
getServiceIcons(this.hass);
|
getServiceIcons(this.hass);
|
||||||
}
|
}
|
||||||
|
if (this._params?.type === "trigger") {
|
||||||
|
this.hass.loadBackendTranslation("triggers");
|
||||||
|
this._fetchManifests();
|
||||||
|
getTriggerIcons(this.hass);
|
||||||
|
this._unsub = subscribeTriggers(this.hass, (triggers) => {
|
||||||
|
this._triggerDescriptions = {
|
||||||
|
...this._triggerDescriptions,
|
||||||
|
...triggers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
this._fullScreen = matchMedia(
|
this._fullScreen = matchMedia(
|
||||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||||
).matches;
|
).matches;
|
||||||
@@ -176,6 +199,10 @@ class DialogAddAutomationElement
|
|||||||
|
|
||||||
public closeDialog() {
|
public closeDialog() {
|
||||||
this.removeKeyboardShortcuts();
|
this.removeKeyboardShortcuts();
|
||||||
|
if (this._unsub) {
|
||||||
|
this._unsub.then((unsub) => unsub());
|
||||||
|
this._unsub = undefined;
|
||||||
|
}
|
||||||
if (this._params) {
|
if (this._params) {
|
||||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
}
|
}
|
||||||
@@ -317,6 +344,11 @@ class DialogAddAutomationElement
|
|||||||
);
|
);
|
||||||
|
|
||||||
const items = flattenGroups(groups).flat();
|
const items = flattenGroups(groups).flat();
|
||||||
|
if (type === "trigger") {
|
||||||
|
items.push(
|
||||||
|
...this._triggers(localize, this._triggerDescriptions, manifests)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (type === "action") {
|
if (type === "action") {
|
||||||
items.push(...this._services(localize, services, manifests));
|
items.push(...this._services(localize, services, manifests));
|
||||||
}
|
}
|
||||||
@@ -339,6 +371,7 @@ class DialogAddAutomationElement
|
|||||||
domains: Set<string> | undefined,
|
domains: Set<string> | undefined,
|
||||||
localize: LocalizeFunc,
|
localize: LocalizeFunc,
|
||||||
services: HomeAssistant["services"],
|
services: HomeAssistant["services"],
|
||||||
|
triggerDescriptions: TriggerDescriptions,
|
||||||
manifests?: DomainManifestLookup
|
manifests?: DomainManifestLookup
|
||||||
): {
|
): {
|
||||||
titleKey?: LocalizeKeys;
|
titleKey?: LocalizeKeys;
|
||||||
@@ -362,7 +395,32 @@ class DialogAddAutomationElement
|
|||||||
services,
|
services,
|
||||||
manifests,
|
manifests,
|
||||||
domains,
|
domains,
|
||||||
collection.groups.serviceGroups
|
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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
groups.push(
|
||||||
|
...this._triggerGroups(
|
||||||
|
localize,
|
||||||
|
triggerDescriptions,
|
||||||
|
manifests,
|
||||||
|
domains,
|
||||||
|
collection.groups.dynamicGroups
|
||||||
? undefined
|
? undefined
|
||||||
: collection.groups.helpers
|
: collection.groups.helpers
|
||||||
? "helper"
|
? "helper"
|
||||||
@@ -429,10 +487,19 @@ class DialogAddAutomationElement
|
|||||||
services: HomeAssistant["services"],
|
services: HomeAssistant["services"],
|
||||||
manifests?: DomainManifestLookup
|
manifests?: DomainManifestLookup
|
||||||
): ListItem[] => {
|
): ListItem[] => {
|
||||||
if (type === "action" && isService(group)) {
|
if (type === "action" && isDynamic(group)) {
|
||||||
return this._services(localize, services, manifests, group);
|
return this._services(localize, services, manifests, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "trigger" && isDynamic(group)) {
|
||||||
|
return this._triggers(
|
||||||
|
localize,
|
||||||
|
this._triggerDescriptions,
|
||||||
|
manifests,
|
||||||
|
group
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const groups = this._getGroups(type, group, collectionIndex);
|
const groups = this._getGroups(type, group, collectionIndex);
|
||||||
|
|
||||||
const result = Object.entries(groups).map(([key, options]) =>
|
const result = Object.entries(groups).map(([key, options]) =>
|
||||||
@@ -514,7 +581,7 @@ class DialogAddAutomationElement
|
|||||||
brand-fallback
|
brand-fallback
|
||||||
></ha-domain-icon>
|
></ha-domain-icon>
|
||||||
`,
|
`,
|
||||||
key: `${SERVICE_PREFIX}${domain}`,
|
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||||
name: domainToName(localize, domain, manifest),
|
name: domainToName(localize, domain, manifest),
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
@@ -525,6 +592,102 @@ class DialogAddAutomationElement
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _triggerGroups = (
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
triggers: TriggerDescriptions,
|
||||||
|
manifests: DomainManifestLookup | undefined,
|
||||||
|
domains: Set<string> | undefined,
|
||||||
|
type: "helper" | "other" | undefined
|
||||||
|
): ListItem[] => {
|
||||||
|
if (!triggers || !manifests) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result: ListItem[] = [];
|
||||||
|
const addedDomains = new Set<string>();
|
||||||
|
Object.keys(triggers).forEach((trigger) => {
|
||||||
|
const domain = getTriggerDomain(trigger);
|
||||||
|
|
||||||
|
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 _triggers = memoizeOne(
|
||||||
|
(
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
triggers: TriggerDescriptions,
|
||||||
|
_manifests: DomainManifestLookup | undefined,
|
||||||
|
group?: string
|
||||||
|
): ListItem[] => {
|
||||||
|
if (!triggers) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const result: ListItem[] = [];
|
||||||
|
|
||||||
|
for (const trigger of Object.keys(triggers)) {
|
||||||
|
const domain = getTriggerDomain(trigger);
|
||||||
|
const triggerName = getTriggerObjectId(trigger);
|
||||||
|
|
||||||
|
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
icon: html`
|
||||||
|
<ha-trigger-icon
|
||||||
|
.hass=${this.hass}
|
||||||
|
.trigger=${trigger}
|
||||||
|
></ha-trigger-icon>
|
||||||
|
`,
|
||||||
|
key: `${DYNAMIC_PREFIX}${trigger}`,
|
||||||
|
name:
|
||||||
|
localize(`component.${domain}.triggers.${triggerName}.name`) ||
|
||||||
|
trigger,
|
||||||
|
description:
|
||||||
|
localize(
|
||||||
|
`component.${domain}.triggers.${triggerName}.description`
|
||||||
|
) || trigger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
private _services = memoizeOne(
|
private _services = memoizeOne(
|
||||||
(
|
(
|
||||||
localize: LocalizeFunc,
|
localize: LocalizeFunc,
|
||||||
@@ -539,8 +702,8 @@ class DialogAddAutomationElement
|
|||||||
|
|
||||||
let domain: string | undefined;
|
let domain: string | undefined;
|
||||||
|
|
||||||
if (isService(group)) {
|
if (isDynamic(group)) {
|
||||||
domain = getService(group!);
|
domain = getValueFromDynamic(group!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const addDomain = (dmn: string) => {
|
const addDomain = (dmn: string) => {
|
||||||
@@ -554,7 +717,7 @@ class DialogAddAutomationElement
|
|||||||
.service=${`${dmn}.${service}`}
|
.service=${`${dmn}.${service}`}
|
||||||
></ha-service-icon>
|
></ha-service-icon>
|
||||||
`,
|
`,
|
||||||
key: `${SERVICE_PREFIX}${dmn}.${service}`,
|
key: `${DYNAMIC_PREFIX}${dmn}.${service}`,
|
||||||
name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${
|
name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${
|
||||||
this.hass.localize(`component.${dmn}.services.${service}.name`) ||
|
this.hass.localize(`component.${dmn}.services.${service}.name`) ||
|
||||||
services[dmn][service]?.name ||
|
services[dmn][service]?.name ||
|
||||||
@@ -668,14 +831,15 @@ class DialogAddAutomationElement
|
|||||||
this._domains,
|
this._domains,
|
||||||
this.hass.localize,
|
this.hass.localize,
|
||||||
this.hass.services,
|
this.hass.services,
|
||||||
|
this._triggerDescriptions,
|
||||||
this._manifests
|
this._manifests
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupName = isService(this._selectedGroup)
|
const groupName = isDynamic(this._selectedGroup)
|
||||||
? domainToName(
|
? domainToName(
|
||||||
this.hass.localize,
|
this.hass.localize,
|
||||||
getService(this._selectedGroup!),
|
getValueFromDynamic(this._selectedGroup!),
|
||||||
this._manifests?.[getService(this._selectedGroup!)]
|
this._manifests?.[getValueFromDynamic(this._selectedGroup!)]
|
||||||
)
|
)
|
||||||
: this.hass.localize(
|
: this.hass.localize(
|
||||||
`ui.panel.config.automation.editor.${this._params!.type}s.groups.${this._selectedGroup}.label` as LocalizeKeys
|
`ui.panel.config.automation.editor.${this._params!.type}s.groups.${this._selectedGroup}.label` as LocalizeKeys
|
||||||
|
|||||||
@@ -452,7 +452,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
|||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
reducedTouchTarget
|
reducedTouchTarget
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type HaAutomationConditionEditor from "../action/ha-automation-action-edi
|
|||||||
import { getAutomationActionType } from "../action/ha-automation-action-row";
|
import { getAutomationActionType } from "../action/ha-automation-action-row";
|
||||||
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
|
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
|
||||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||||
import "../trigger/ha-automation-trigger-editor";
|
|
||||||
import "./ha-automation-sidebar-card";
|
import "./ha-automation-sidebar-card";
|
||||||
|
|
||||||
@customElement("ha-automation-sidebar-action")
|
@customElement("ha-automation-sidebar-action")
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import "../../../../components/ha-dialog-header";
|
|||||||
import "../../../../components/ha-icon-button";
|
import "../../../../components/ha-icon-button";
|
||||||
import "../../../../components/ha-md-button-menu";
|
import "../../../../components/ha-md-button-menu";
|
||||||
import "../../../../components/ha-md-divider";
|
import "../../../../components/ha-md-divider";
|
||||||
import "../../../../components/ha-md-menu-item";
|
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import "../ha-automation-editor-warning";
|
import "../ha-automation-editor-warning";
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import { html, LitElement, nothing } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, query } from "lit/decorators";
|
import { customElement, property, query } from "lit/decorators";
|
||||||
|
import "../../../../components/ha-md-divider";
|
||||||
|
import "../../../../components/ha-md-menu-item";
|
||||||
|
import "../../../../components/ha-svg-icon";
|
||||||
import type { OptionSidebarConfig } from "../../../../data/automation";
|
import type { OptionSidebarConfig } from "../../../../data/automation";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import { isMac } from "../../../../util/is_mac";
|
import { isMac } from "../../../../util/is_mac";
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ import { customElement, property, query, state } from "lit/decorators";
|
|||||||
import { keyed } from "lit/directives/keyed";
|
import { keyed } from "lit/directives/keyed";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
import { handleStructError } from "../../../../common/structs/handle-errors";
|
import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||||
import type { TriggerSidebarConfig } from "../../../../data/automation";
|
import type {
|
||||||
import { isTriggerList } from "../../../../data/trigger";
|
LegacyTrigger,
|
||||||
|
TriggerSidebarConfig,
|
||||||
|
} from "../../../../data/automation";
|
||||||
|
import {
|
||||||
|
getTriggerDomain,
|
||||||
|
getTriggerObjectId,
|
||||||
|
isTriggerList,
|
||||||
|
} from "../../../../data/trigger";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import { isMac } from "../../../../util/is_mac";
|
import { isMac } from "../../../../util/is_mac";
|
||||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||||
@@ -63,8 +70,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
|||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
const rowDisabled =
|
const rowDisabled =
|
||||||
this.disabled ||
|
"enabled" in this.config.config && this.config.config.enabled === false;
|
||||||
("enabled" in this.config.config && this.config.config.enabled === false);
|
|
||||||
const type = isTriggerList(this.config.config)
|
const type = isTriggerList(this.config.config)
|
||||||
? "list"
|
? "list"
|
||||||
: this.config.config.trigger;
|
: this.config.config.trigger;
|
||||||
@@ -73,9 +79,18 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
|||||||
"ui.panel.config.automation.editor.triggers.trigger"
|
"ui.panel.config.automation.editor.triggers.trigger"
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = this.hass.localize(
|
const domain =
|
||||||
`ui.panel.config.automation.editor.triggers.type.${type}.label`
|
"trigger" in this.config.config &&
|
||||||
);
|
getTriggerDomain(this.config.config.trigger);
|
||||||
|
const triggerName =
|
||||||
|
"trigger" in this.config.config &&
|
||||||
|
getTriggerObjectId(this.config.config.trigger);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
this.hass.localize(
|
||||||
|
`ui.panel.config.automation.editor.triggers.type.${type as LegacyTrigger["trigger"]}.label`
|
||||||
|
) ||
|
||||||
|
this.hass.localize(`component.${domain}.triggers.${triggerName}.name`);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-automation-sidebar-card
|
<ha-automation-sidebar-card
|
||||||
@@ -269,6 +284,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
|||||||
class="sidebar-editor"
|
class="sidebar-editor"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.trigger=${this.config.config}
|
.trigger=${this.config.config}
|
||||||
|
.description=${this.config.description}
|
||||||
@value-changed=${this._valueChangedSidebar}
|
@value-changed=${this._valueChangedSidebar}
|
||||||
@yaml-changed=${this._yamlChangedSidebar}
|
@yaml-changed=${this._yamlChangedSidebar}
|
||||||
.uiSupported=${this.config.uiSupported}
|
.uiSupported=${this.config.uiSupported}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import "../../../../components/ha-yaml-editor";
|
|||||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||||
import type { Trigger } from "../../../../data/automation";
|
import type { Trigger } from "../../../../data/automation";
|
||||||
import { migrateAutomationTrigger } from "../../../../data/automation";
|
import { migrateAutomationTrigger } from "../../../../data/automation";
|
||||||
|
import type { TriggerDescription } from "../../../../data/trigger";
|
||||||
import { isTriggerList } from "../../../../data/trigger";
|
import { isTriggerList } from "../../../../data/trigger";
|
||||||
import { haStyle } from "../../../../resources/styles";
|
import { haStyle } from "../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import "../ha-automation-editor-warning";
|
import "../ha-automation-editor-warning";
|
||||||
|
import "./types/ha-automation-trigger-platform";
|
||||||
|
|
||||||
@customElement("ha-automation-trigger-editor")
|
@customElement("ha-automation-trigger-editor")
|
||||||
export default class HaAutomationTriggerEditor extends LitElement {
|
export default class HaAutomationTriggerEditor extends LitElement {
|
||||||
@@ -31,6 +33,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
|||||||
|
|
||||||
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
|
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public description?: TriggerDescription;
|
||||||
|
|
||||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
@@ -87,11 +91,18 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div @value-changed=${this._onUiChanged}>
|
<div @value-changed=${this._onUiChanged}>
|
||||||
${dynamicElement(`ha-automation-trigger-${type}`, {
|
${this.description
|
||||||
hass: this.hass,
|
? html`<ha-automation-trigger-platform
|
||||||
trigger: this.trigger,
|
.hass=${this.hass}
|
||||||
disabled: this.disabled,
|
.trigger=${this.trigger}
|
||||||
})}
|
.description=${this.description}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
></ha-automation-trigger-platform>`
|
||||||
|
: dynamicElement(`ha-automation-trigger-${type}`, {
|
||||||
|
hass: this.hass,
|
||||||
|
trigger: this.trigger,
|
||||||
|
disabled: this.disabled,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ import "../../../../components/ha-md-button-menu";
|
|||||||
import "../../../../components/ha-md-divider";
|
import "../../../../components/ha-md-divider";
|
||||||
import "../../../../components/ha-md-menu-item";
|
import "../../../../components/ha-md-menu-item";
|
||||||
import "../../../../components/ha-svg-icon";
|
import "../../../../components/ha-svg-icon";
|
||||||
|
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
|
||||||
import type {
|
import type {
|
||||||
AutomationClipboard,
|
AutomationClipboard,
|
||||||
Trigger,
|
Trigger,
|
||||||
|
TriggerList,
|
||||||
TriggerSidebarConfig,
|
TriggerSidebarConfig,
|
||||||
} from "../../../../data/automation";
|
} from "../../../../data/automation";
|
||||||
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
|
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
|
||||||
@@ -50,7 +52,8 @@ import { describeTrigger } from "../../../../data/automation_i18n";
|
|||||||
import { validateConfig } from "../../../../data/config";
|
import { validateConfig } from "../../../../data/config";
|
||||||
import { fullEntitiesContext } from "../../../../data/context";
|
import { fullEntitiesContext } from "../../../../data/context";
|
||||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
||||||
import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
|
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||||
|
import { isTriggerList } from "../../../../data/trigger";
|
||||||
import {
|
import {
|
||||||
showAlertDialog,
|
showAlertDialog,
|
||||||
showPromptDialog,
|
showPromptDialog,
|
||||||
@@ -72,6 +75,7 @@ import "./types/ha-automation-trigger-list";
|
|||||||
import "./types/ha-automation-trigger-mqtt";
|
import "./types/ha-automation-trigger-mqtt";
|
||||||
import "./types/ha-automation-trigger-numeric_state";
|
import "./types/ha-automation-trigger-numeric_state";
|
||||||
import "./types/ha-automation-trigger-persistent_notification";
|
import "./types/ha-automation-trigger-persistent_notification";
|
||||||
|
import "./types/ha-automation-trigger-platform";
|
||||||
import "./types/ha-automation-trigger-state";
|
import "./types/ha-automation-trigger-state";
|
||||||
import "./types/ha-automation-trigger-sun";
|
import "./types/ha-automation-trigger-sun";
|
||||||
import "./types/ha-automation-trigger-tag";
|
import "./types/ha-automation-trigger-tag";
|
||||||
@@ -137,6 +141,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
|
|
||||||
@state() private _warnings?: string[];
|
@state() private _warnings?: string[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public triggerDescriptions: TriggerDescriptions = {};
|
||||||
|
|
||||||
@property({ type: Boolean }) public narrow = false;
|
@property({ type: Boolean }) public narrow = false;
|
||||||
|
|
||||||
@query("ha-automation-trigger-editor")
|
@query("ha-automation-trigger-editor")
|
||||||
@@ -178,18 +185,24 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _renderRow() {
|
private _renderRow() {
|
||||||
const type = this._getType(this.trigger);
|
const type = this._getType(this.trigger, this.triggerDescriptions);
|
||||||
|
|
||||||
const supported = this._uiSupported(type);
|
const supported = this._uiSupported(type);
|
||||||
|
|
||||||
const yamlMode = this._yamlMode || !supported;
|
const yamlMode = this._yamlMode || !supported;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-svg-icon
|
${type === "list"
|
||||||
slot="leading-icon"
|
? html`<ha-svg-icon
|
||||||
class="trigger-icon"
|
slot="leading-icon"
|
||||||
.path=${TRIGGER_ICONS[type]}
|
class="trigger-icon"
|
||||||
></ha-svg-icon>
|
.path=${TRIGGER_ICONS[type]}
|
||||||
|
></ha-svg-icon>`
|
||||||
|
: html`<ha-trigger-icon
|
||||||
|
slot="leading-icon"
|
||||||
|
.hass=${this.hass}
|
||||||
|
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
|
||||||
|
></ha-trigger-icon>`}
|
||||||
<h3 slot="header">
|
<h3 slot="header">
|
||||||
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -393,6 +406,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
<ha-automation-trigger-editor
|
<ha-automation-trigger-editor
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.trigger=${this.trigger}
|
.trigger=${this.trigger}
|
||||||
|
.description=${"trigger" in this.trigger
|
||||||
|
? this.triggerDescriptions[this.trigger.trigger]
|
||||||
|
: undefined}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
.yamlMode=${this._yamlMode}
|
.yamlMode=${this._yamlMode}
|
||||||
.uiSupported=${supported}
|
.uiSupported=${supported}
|
||||||
@@ -552,6 +568,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openSidebar(trigger?: Trigger): void {
|
public openSidebar(trigger?: Trigger): void {
|
||||||
|
trigger = trigger || this.trigger;
|
||||||
fireEvent(this, "open-sidebar", {
|
fireEvent(this, "open-sidebar", {
|
||||||
save: (value) => {
|
save: (value) => {
|
||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
@@ -576,8 +593,14 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
duplicate: this._duplicateTrigger,
|
duplicate: this._duplicateTrigger,
|
||||||
cut: this._cutTrigger,
|
cut: this._cutTrigger,
|
||||||
insertAfter: this._insertAfter,
|
insertAfter: this._insertAfter,
|
||||||
config: trigger || this.trigger,
|
config: trigger,
|
||||||
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
|
uiSupported: this._uiSupported(
|
||||||
|
this._getType(trigger, this.triggerDescriptions)
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
"trigger" in trigger
|
||||||
|
? this.triggerDescriptions[trigger.trigger]
|
||||||
|
: undefined,
|
||||||
yamlMode: this._yamlMode,
|
yamlMode: this._yamlMode,
|
||||||
} satisfies TriggerSidebarConfig);
|
} satisfies TriggerSidebarConfig);
|
||||||
this._selected = true;
|
this._selected = true;
|
||||||
@@ -759,8 +782,18 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getType = memoizeOne((trigger: Trigger) =>
|
private _getType = memoizeOne(
|
||||||
isTriggerList(trigger) ? "list" : trigger.trigger
|
(trigger: Trigger, triggerDescriptions: TriggerDescriptions) => {
|
||||||
|
if (isTriggerList(trigger)) {
|
||||||
|
return "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.trigger in triggerDescriptions) {
|
||||||
|
return "platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trigger.trigger;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
private _uiSupported = memoizeOne(
|
private _uiSupported = memoizeOne(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
|
|||||||
import { html, LitElement, nothing } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { repeat } from "lit/directives/repeat";
|
import { repeat } from "lit/directives/repeat";
|
||||||
|
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||||
import { storage } from "../../../../common/decorators/storage";
|
import { storage } from "../../../../common/decorators/storage";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||||
@@ -12,12 +13,16 @@ import "../../../../components/ha-button";
|
|||||||
import "../../../../components/ha-button-menu";
|
import "../../../../components/ha-button-menu";
|
||||||
import "../../../../components/ha-sortable";
|
import "../../../../components/ha-sortable";
|
||||||
import "../../../../components/ha-svg-icon";
|
import "../../../../components/ha-svg-icon";
|
||||||
import type {
|
import {
|
||||||
AutomationClipboard,
|
getValueFromDynamic,
|
||||||
Trigger,
|
isDynamic,
|
||||||
TriggerList,
|
type AutomationClipboard,
|
||||||
|
type Trigger,
|
||||||
|
type TriggerList,
|
||||||
} from "../../../../data/automation";
|
} from "../../../../data/automation";
|
||||||
import { isTriggerList } from "../../../../data/trigger";
|
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||||
|
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||||
|
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import {
|
import {
|
||||||
PASTE_VALUE,
|
PASTE_VALUE,
|
||||||
@@ -26,10 +31,9 @@ import {
|
|||||||
import { automationRowsStyles } from "../styles";
|
import { automationRowsStyles } from "../styles";
|
||||||
import "./ha-automation-trigger-row";
|
import "./ha-automation-trigger-row";
|
||||||
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
|
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
|
||||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
|
||||||
|
|
||||||
@customElement("ha-automation-trigger")
|
@customElement("ha-automation-trigger")
|
||||||
export default class HaAutomationTrigger extends LitElement {
|
export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false }) public triggers!: Trigger[];
|
@property({ attribute: false }) public triggers!: Trigger[];
|
||||||
@@ -62,6 +66,23 @@ export default class HaAutomationTrigger extends LitElement {
|
|||||||
|
|
||||||
private _triggerKeys = new WeakMap<Trigger, string>();
|
private _triggerKeys = new WeakMap<Trigger, string>();
|
||||||
|
|
||||||
|
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||||
|
|
||||||
|
protected hassSubscribe() {
|
||||||
|
return [
|
||||||
|
subscribeTriggers(this.hass, (triggers) => this._addTriggers(triggers)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addTriggers(triggers: TriggerDescriptions) {
|
||||||
|
this._triggerDescriptions = { ...this._triggerDescriptions, ...triggers };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected firstUpdated(changedProps: PropertyValues) {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
this.hass.loadBackendTranslation("triggers");
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
return html`
|
return html`
|
||||||
<ha-sortable
|
<ha-sortable
|
||||||
@@ -85,6 +106,7 @@ export default class HaAutomationTrigger extends LitElement {
|
|||||||
.first=${idx === 0}
|
.first=${idx === 0}
|
||||||
.last=${idx === this.triggers.length - 1}
|
.last=${idx === this.triggers.length - 1}
|
||||||
.trigger=${trg}
|
.trigger=${trg}
|
||||||
|
.triggerDescriptions=${this._triggerDescriptions}
|
||||||
@duplicate=${this._duplicateTrigger}
|
@duplicate=${this._duplicateTrigger}
|
||||||
@insert-after=${this._insertAfter}
|
@insert-after=${this._insertAfter}
|
||||||
@move-down=${this._moveDown}
|
@move-down=${this._moveDown}
|
||||||
@@ -156,6 +178,10 @@ export default class HaAutomationTrigger extends LitElement {
|
|||||||
let triggers: Trigger[];
|
let triggers: Trigger[];
|
||||||
if (value === PASTE_VALUE) {
|
if (value === PASTE_VALUE) {
|
||||||
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
|
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
|
||||||
|
} else if (isDynamic(value)) {
|
||||||
|
triggers = this.triggers.concat({
|
||||||
|
trigger: getValueFromDynamic(value),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
||||||
const elClass = customElements.get(
|
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 { PlatformTrigger } from "../../../../../data/automation";
|
||||||
|
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||||
|
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||||
|
import type { TargetSelector } from "../../../../../data/selector";
|
||||||
|
import {
|
||||||
|
getTriggerDomain,
|
||||||
|
getTriggerObjectId,
|
||||||
|
type TriggerDescription,
|
||||||
|
} from "../../../../../data/trigger";
|
||||||
|
import type { HomeAssistant } from "../../../../../types";
|
||||||
|
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||||
|
|
||||||
|
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||||
|
field.selector &&
|
||||||
|
!field.required &&
|
||||||
|
!("boolean" in field.selector && field.default);
|
||||||
|
|
||||||
|
@customElement("ha-automation-trigger-platform")
|
||||||
|
export class HaPlatformTrigger extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public trigger!: PlatformTrigger;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public description?: TriggerDescription;
|
||||||
|
|
||||||
|
@property({ type: Boolean }) public disabled = false;
|
||||||
|
|
||||||
|
@state() private _checkedKeys = new Set();
|
||||||
|
|
||||||
|
@state() private _manifest?: IntegrationManifest;
|
||||||
|
|
||||||
|
public static get defaultConfig(): PlatformTrigger {
|
||||||
|
return { trigger: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||||
|
super.willUpdate(changedProperties);
|
||||||
|
if (!this.hasUpdated) {
|
||||||
|
this.hass.loadBackendTranslation("triggers");
|
||||||
|
this.hass.loadBackendTranslation("selector");
|
||||||
|
}
|
||||||
|
if (!changedProperties.has("trigger")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldValue = changedProperties.get("trigger") as
|
||||||
|
| undefined
|
||||||
|
| this["trigger"];
|
||||||
|
|
||||||
|
// Fetch the manifest if we have a trigger selected and the trigger domain changed.
|
||||||
|
// If no trigger is selected, clear the manifest.
|
||||||
|
if (this.trigger?.trigger) {
|
||||||
|
const domain = getTriggerDomain(this.trigger.trigger);
|
||||||
|
|
||||||
|
const oldDomain = getTriggerDomain(oldValue?.trigger || "");
|
||||||
|
|
||||||
|
if (domain !== oldDomain) {
|
||||||
|
this._fetchManifest(domain);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._manifest = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
const domain = getTriggerDomain(this.trigger.trigger);
|
||||||
|
const triggerName = getTriggerObjectId(this.trigger.trigger);
|
||||||
|
|
||||||
|
const description = this.hass.localize(
|
||||||
|
`component.${domain}.triggers.${triggerName}.description`
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerDesc = this.description;
|
||||||
|
|
||||||
|
const shouldRenderDataYaml = !triggerDesc?.fields;
|
||||||
|
|
||||||
|
const hasOptional = Boolean(
|
||||||
|
triggerDesc?.fields &&
|
||||||
|
Object.values(triggerDesc.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>
|
||||||
|
${triggerDesc && "target" in triggerDesc
|
||||||
|
? 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(triggerDesc.target)}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
@value-changed=${this._targetChanged}
|
||||||
|
.value=${this.trigger?.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.trigger?.options}
|
||||||
|
@value-changed=${this._dataChanged}
|
||||||
|
></ha-yaml-editor>`
|
||||||
|
: Object.entries(triggerDesc.fields).map(([fieldName, dataField]) =>
|
||||||
|
this._renderField(
|
||||||
|
fieldName,
|
||||||
|
dataField,
|
||||||
|
hasOptional,
|
||||||
|
domain,
|
||||||
|
triggerName
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _targetSelector = memoizeOne(
|
||||||
|
(targetSelector: TargetSelector["target"] | null | undefined) =>
|
||||||
|
targetSelector ? { target: { ...targetSelector } } : { target: {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
private _renderField = (
|
||||||
|
fieldName: string,
|
||||||
|
dataField: TriggerDescription["fields"][string],
|
||||||
|
hasOptional: boolean,
|
||||||
|
domain: string | undefined,
|
||||||
|
triggerName: 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.trigger?.options &&
|
||||||
|
this.trigger.options[fieldName] !== undefined)}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
@change=${this._checkboxChanged}
|
||||||
|
slot="prefix"
|
||||||
|
></ha-checkbox>`}
|
||||||
|
<span slot="heading"
|
||||||
|
>${this.hass.localize(
|
||||||
|
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.name`
|
||||||
|
) || triggerName}</span
|
||||||
|
>
|
||||||
|
<span slot="description"
|
||||||
|
>${this.hass.localize(
|
||||||
|
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.description`
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<ha-selector
|
||||||
|
.disabled=${this.disabled ||
|
||||||
|
(showOptional &&
|
||||||
|
!this._checkedKeys.has(fieldName) &&
|
||||||
|
(!this.trigger?.options ||
|
||||||
|
this.trigger.options[fieldName] === undefined))}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.selector=${selector}
|
||||||
|
.context=${this._generateContext(dataField)}
|
||||||
|
.key=${fieldName}
|
||||||
|
@value-changed=${this._dataChanged}
|
||||||
|
.value=${this.trigger?.options
|
||||||
|
? this.trigger.options[fieldName]
|
||||||
|
: undefined}
|
||||||
|
.placeholder=${dataField.default}
|
||||||
|
.localizeValue=${this._localizeValueCallback}
|
||||||
|
></ha-selector>
|
||||||
|
</ha-settings-row>`
|
||||||
|
: nothing;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _generateContext(
|
||||||
|
field: TriggerDescription["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.trigger.target
|
||||||
|
: this.trigger.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.trigger?.options?.[key] === value ||
|
||||||
|
((!this.trigger?.options || !(key in this.trigger.options)) &&
|
||||||
|
(value === "" || value === undefined))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { ...this.trigger?.options, [key]: value };
|
||||||
|
|
||||||
|
if (
|
||||||
|
value === "" ||
|
||||||
|
value === undefined ||
|
||||||
|
(typeof value === "object" && !Object.keys(value).length)
|
||||||
|
) {
|
||||||
|
delete options[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent(this, "value-changed", {
|
||||||
|
value: {
|
||||||
|
...this.trigger,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _targetChanged(ev: CustomEvent): void {
|
||||||
|
ev.stopPropagation();
|
||||||
|
fireEvent(this, "value-changed", {
|
||||||
|
value: {
|
||||||
|
...this.trigger,
|
||||||
|
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.trigger?.options,
|
||||||
|
[key]: defaultValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._checkedKeys.delete(key);
|
||||||
|
options = { ...this.trigger?.options };
|
||||||
|
delete options[key];
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
fireEvent(this, "value-changed", {
|
||||||
|
value: {
|
||||||
|
...this.trigger,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.requestUpdate("_checkedKeys");
|
||||||
|
}
|
||||||
|
|
||||||
|
private _localizeValueCallback = (key: string) => {
|
||||||
|
if (!this.trigger?.trigger) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return this.hass.localize(
|
||||||
|
`component.${computeDomain(this.trigger.trigger)}.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-trigger-platform": HaPlatformTrigger;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,13 +182,11 @@ class HaBlueprintOverview extends LitElement {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
groupable: true,
|
groupable: true,
|
||||||
direction: "asc",
|
|
||||||
},
|
},
|
||||||
path: {
|
path: {
|
||||||
title: localize("ui.panel.config.blueprint.overview.headers.file_name"),
|
title: localize("ui.panel.config.blueprint.overview.headers.file_name"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
direction: "asc",
|
|
||||||
flex: 2,
|
flex: 2,
|
||||||
},
|
},
|
||||||
fullpath: {
|
fullpath: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mdiTag, mdiPlus } from "@mdi/js";
|
import { mdiPlus, mdiTag } from "@mdi/js";
|
||||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import { html, LitElement } from "lit";
|
import { html, LitElement } from "lit";
|
||||||
@@ -194,8 +194,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this._notFoundLabel}
|
||||||
"ui.components.category-picker.no_match"
|
.emptyLabel=${this.hass.localize(
|
||||||
|
"ui.components.category-picker.no_categories"
|
||||||
)}
|
)}
|
||||||
.placeholder=${placeholder}
|
.placeholder=${placeholder}
|
||||||
.value=${this.value}
|
.value=${this.value}
|
||||||
@@ -254,6 +255,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
|
|||||||
fireEvent(this, "change");
|
fireEvent(this, "change");
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _notFoundLabel = (search: string) =>
|
||||||
|
this.hass.localize("ui.components.category-picker.no_match", {
|
||||||
|
term: html`<b>‘${search}’</b>`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -771,7 +771,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
|
|||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
reducedTouchTarget
|
reducedTouchTarget
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
EnergySource,
|
EnergySource,
|
||||||
FlowFromGridSourceEnergyPreference,
|
FlowFromGridSourceEnergyPreference,
|
||||||
FlowToGridSourceEnergyPreference,
|
FlowToGridSourceEnergyPreference,
|
||||||
|
GridPowerSourceEnergyPreference,
|
||||||
GridSourceTypeEnergyPreference,
|
GridSourceTypeEnergyPreference,
|
||||||
} from "../../../../data/energy";
|
} from "../../../../data/energy";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
|
|||||||
import {
|
import {
|
||||||
showEnergySettingsGridFlowFromDialog,
|
showEnergySettingsGridFlowFromDialog,
|
||||||
showEnergySettingsGridFlowToDialog,
|
showEnergySettingsGridFlowToDialog,
|
||||||
|
showEnergySettingsGridPowerDialog,
|
||||||
} from "../dialogs/show-dialogs-energy";
|
} from "../dialogs/show-dialogs-energy";
|
||||||
import "./ha-energy-validation-result";
|
import "./ha-energy-validation-result";
|
||||||
import { energyCardStyles } from "./styles";
|
import { energyCardStyles } from "./styles";
|
||||||
@@ -226,6 +228,58 @@ export class EnergyGridSettings extends LitElement {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
${this.hass.localize("ui.panel.config.energy.grid.grid_power")}
|
||||||
|
</h3>
|
||||||
|
${gridSource.power?.map((power) => {
|
||||||
|
const entityState = this.hass.states[power.stat_rate];
|
||||||
|
return html`
|
||||||
|
<div class="row" .source=${power}>
|
||||||
|
${entityState?.attributes.icon
|
||||||
|
? html`<ha-icon
|
||||||
|
.icon=${entityState.attributes.icon}
|
||||||
|
></ha-icon>`
|
||||||
|
: html`<ha-svg-icon
|
||||||
|
.path=${mdiTransmissionTower}
|
||||||
|
></ha-svg-icon>`}
|
||||||
|
<span class="content"
|
||||||
|
>${getStatisticLabel(
|
||||||
|
this.hass,
|
||||||
|
power.stat_rate,
|
||||||
|
this.statsMetadata?.[power.stat_rate]
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.edit_power"
|
||||||
|
)}
|
||||||
|
@click=${this._editPowerSource}
|
||||||
|
.path=${mdiPencil}
|
||||||
|
></ha-icon-button>
|
||||||
|
<ha-icon-button
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.delete_power"
|
||||||
|
)}
|
||||||
|
@click=${this._deletePowerSource}
|
||||||
|
.path=${mdiDelete}
|
||||||
|
></ha-icon-button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
<div class="row border-bottom">
|
||||||
|
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon>
|
||||||
|
<ha-button
|
||||||
|
@click=${this._addPowerSource}
|
||||||
|
appearance="filled"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon
|
||||||
|
>${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.add_power"
|
||||||
|
)}</ha-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
"ui.panel.config.energy.grid.grid_carbon_footprint"
|
"ui.panel.config.energy.grid.grid_carbon_footprint"
|
||||||
@@ -499,6 +553,97 @@ export class EnergyGridSettings extends LitElement {
|
|||||||
await this._savePreferences(cleanedPreferences);
|
await this._savePreferences(cleanedPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _addPowerSource() {
|
||||||
|
const gridSource = this.preferences.energy_sources.find(
|
||||||
|
(src) => src.type === "grid"
|
||||||
|
) as GridSourceTypeEnergyPreference | undefined;
|
||||||
|
showEnergySettingsGridPowerDialog(this, {
|
||||||
|
grid_source: gridSource,
|
||||||
|
saveCallback: async (power) => {
|
||||||
|
let preferences: EnergyPreferences;
|
||||||
|
if (!gridSource) {
|
||||||
|
preferences = {
|
||||||
|
...this.preferences,
|
||||||
|
energy_sources: [
|
||||||
|
...this.preferences.energy_sources,
|
||||||
|
{
|
||||||
|
...emptyGridSourceEnergyPreference(),
|
||||||
|
power: [power],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
preferences = {
|
||||||
|
...this.preferences,
|
||||||
|
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||||
|
src.type === "grid"
|
||||||
|
? { ...src, power: [...(gridSource.power || []), power] }
|
||||||
|
: src
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this._savePreferences(preferences);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _editPowerSource(ev) {
|
||||||
|
const origSource: GridPowerSourceEnergyPreference =
|
||||||
|
ev.currentTarget.closest(".row").source;
|
||||||
|
const gridSource = this.preferences.energy_sources.find(
|
||||||
|
(src) => src.type === "grid"
|
||||||
|
) as GridSourceTypeEnergyPreference | undefined;
|
||||||
|
showEnergySettingsGridPowerDialog(this, {
|
||||||
|
source: { ...origSource },
|
||||||
|
grid_source: gridSource,
|
||||||
|
saveCallback: async (source) => {
|
||||||
|
const power =
|
||||||
|
energySourcesByType(this.preferences).grid![0].power || [];
|
||||||
|
|
||||||
|
const preferences: EnergyPreferences = {
|
||||||
|
...this.preferences,
|
||||||
|
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||||
|
src.type === "grid"
|
||||||
|
? {
|
||||||
|
...src,
|
||||||
|
power: power.map((p) => (p === origSource ? source : p)),
|
||||||
|
}
|
||||||
|
: src
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await this._savePreferences(preferences);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _deletePowerSource(ev) {
|
||||||
|
const sourceToDelete: GridPowerSourceEnergyPreference =
|
||||||
|
ev.currentTarget.closest(".row").source;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const power =
|
||||||
|
energySourcesByType(this.preferences).grid![0].power?.filter(
|
||||||
|
(p) => p !== sourceToDelete
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const preferences: EnergyPreferences = {
|
||||||
|
...this.preferences,
|
||||||
|
energy_sources: this.preferences.energy_sources.map((source) =>
|
||||||
|
source.type === "grid" ? { ...source, power } : source
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanedPreferences = this._removeEmptySources(preferences);
|
||||||
|
await this._savePreferences(cleanedPreferences);
|
||||||
|
}
|
||||||
|
|
||||||
private _removeEmptySources(preferences: EnergyPreferences) {
|
private _removeEmptySources(preferences: EnergyPreferences) {
|
||||||
// Check if grid sources became an empty type and remove if so
|
// Check if grid sources became an empty type and remove if so
|
||||||
preferences.energy_sources = preferences.energy_sources.reduce<
|
preferences.energy_sources = preferences.energy_sources.reduce<
|
||||||
@@ -507,7 +652,8 @@ export class EnergyGridSettings extends LitElement {
|
|||||||
if (
|
if (
|
||||||
source.type !== "grid" ||
|
source.type !== "grid" ||
|
||||||
source.flow_from.length > 0 ||
|
source.flow_from.length > 0 ||
|
||||||
source.flow_to.length > 0
|
source.flow_to.length > 0 ||
|
||||||
|
(source.power && source.power.length > 0)
|
||||||
) {
|
) {
|
||||||
acc.push(source);
|
acc.push(source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||||
|
|
||||||
const energyUnitClasses = ["energy"];
|
const energyUnitClasses = ["energy"];
|
||||||
|
const powerUnitClasses = ["power"];
|
||||||
|
|
||||||
@customElement("dialog-energy-battery-settings")
|
@customElement("dialog-energy-battery-settings")
|
||||||
export class DialogEnergyBatterySettings
|
export class DialogEnergyBatterySettings
|
||||||
@@ -32,10 +33,14 @@ export class DialogEnergyBatterySettings
|
|||||||
|
|
||||||
@state() private _energy_units?: string[];
|
@state() private _energy_units?: string[];
|
||||||
|
|
||||||
|
@state() private _power_units?: string[];
|
||||||
|
|
||||||
@state() private _error?: string;
|
@state() private _error?: string;
|
||||||
|
|
||||||
private _excludeList?: string[];
|
private _excludeList?: string[];
|
||||||
|
|
||||||
|
private _excludeListPower?: string[];
|
||||||
|
|
||||||
public async showDialog(
|
public async showDialog(
|
||||||
params: EnergySettingsBatteryDialogParams
|
params: EnergySettingsBatteryDialogParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -46,6 +51,9 @@ export class DialogEnergyBatterySettings
|
|||||||
this._energy_units = (
|
this._energy_units = (
|
||||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||||
).units;
|
).units;
|
||||||
|
this._power_units = (
|
||||||
|
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||||
|
).units;
|
||||||
const allSources: string[] = [];
|
const allSources: string[] = [];
|
||||||
this._params.battery_sources.forEach((entry) => {
|
this._params.battery_sources.forEach((entry) => {
|
||||||
allSources.push(entry.stat_energy_from);
|
allSources.push(entry.stat_energy_from);
|
||||||
@@ -56,6 +64,9 @@ export class DialogEnergyBatterySettings
|
|||||||
id !== this._source?.stat_energy_from &&
|
id !== this._source?.stat_energy_from &&
|
||||||
id !== this._source?.stat_energy_to
|
id !== this._source?.stat_energy_to
|
||||||
);
|
);
|
||||||
|
this._excludeListPower = this._params.battery_sources
|
||||||
|
.map((entry) => entry.stat_rate)
|
||||||
|
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeDialog() {
|
public closeDialog() {
|
||||||
@@ -72,8 +83,6 @@ export class DialogEnergyBatterySettings
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-dialog
|
<ha-dialog
|
||||||
open
|
open
|
||||||
@@ -85,12 +94,6 @@ export class DialogEnergyBatterySettings
|
|||||||
@closed=${this.closeDialog}
|
@closed=${this.closeDialog}
|
||||||
>
|
>
|
||||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||||
<div>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.energy.battery.dialog.entity_para",
|
|
||||||
{ unit: pickableUnit }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ha-statistic-picker
|
<ha-statistic-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -105,6 +108,10 @@ export class DialogEnergyBatterySettings
|
|||||||
this._source.stat_energy_from,
|
this._source.stat_energy_from,
|
||||||
]}
|
]}
|
||||||
@value-changed=${this._statisticToChanged}
|
@value-changed=${this._statisticToChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.battery.dialog.energy_helper_into",
|
||||||
|
{ unit: this._energy_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
dialogInitialFocus
|
dialogInitialFocus
|
||||||
></ha-statistic-picker>
|
></ha-statistic-picker>
|
||||||
|
|
||||||
@@ -121,6 +128,25 @@ export class DialogEnergyBatterySettings
|
|||||||
this._source.stat_energy_to,
|
this._source.stat_energy_to,
|
||||||
]}
|
]}
|
||||||
@value-changed=${this._statisticFromChanged}
|
@value-changed=${this._statisticFromChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.battery.dialog.energy_helper_out",
|
||||||
|
{ unit: this._energy_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
|
></ha-statistic-picker>
|
||||||
|
|
||||||
|
<ha-statistic-picker
|
||||||
|
.hass=${this.hass}
|
||||||
|
.includeUnitClass=${powerUnitClasses}
|
||||||
|
.value=${this._source.stat_rate}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.battery.dialog.power"
|
||||||
|
)}
|
||||||
|
.excludeStatistics=${this._excludeListPower}
|
||||||
|
@value-changed=${this._powerChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.battery.dialog.power_helper",
|
||||||
|
{ unit: this._power_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
></ha-statistic-picker>
|
></ha-statistic-picker>
|
||||||
|
|
||||||
<ha-button
|
<ha-button
|
||||||
@@ -150,6 +176,10 @@ export class DialogEnergyBatterySettings
|
|||||||
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _powerChanged(ev: CustomEvent<{ value: string }>) {
|
||||||
|
this._source = { ...this._source!, stat_rate: ev.detail.value };
|
||||||
|
}
|
||||||
|
|
||||||
private async _save() {
|
private async _save() {
|
||||||
try {
|
try {
|
||||||
await this._params!.saveCallback(this._source!);
|
await this._params!.saveCallback(this._source!);
|
||||||
@@ -168,7 +198,11 @@ export class DialogEnergyBatterySettings
|
|||||||
--mdc-dialog-max-width: 430px;
|
--mdc-dialog-max-width: 430px;
|
||||||
}
|
}
|
||||||
ha-statistic-picker {
|
ha-statistic-picker {
|
||||||
width: 100%;
|
display: block;
|
||||||
|
margin-bottom: var(--ha-space-4);
|
||||||
|
}
|
||||||
|
ha-statistic-picker:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { HomeAssistant } from "../../../../types";
|
|||||||
import type { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
|
import type { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
|
||||||
|
|
||||||
const energyUnitClasses = ["energy"];
|
const energyUnitClasses = ["energy"];
|
||||||
|
const powerUnitClasses = ["power"];
|
||||||
|
|
||||||
@customElement("dialog-energy-device-settings")
|
@customElement("dialog-energy-device-settings")
|
||||||
export class DialogEnergyDeviceSettings
|
export class DialogEnergyDeviceSettings
|
||||||
@@ -35,10 +36,14 @@ export class DialogEnergyDeviceSettings
|
|||||||
|
|
||||||
@state() private _energy_units?: string[];
|
@state() private _energy_units?: string[];
|
||||||
|
|
||||||
|
@state() private _power_units?: string[];
|
||||||
|
|
||||||
@state() private _error?: string;
|
@state() private _error?: string;
|
||||||
|
|
||||||
private _excludeList?: string[];
|
private _excludeList?: string[];
|
||||||
|
|
||||||
|
private _excludeListPower?: string[];
|
||||||
|
|
||||||
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
||||||
|
|
||||||
public async showDialog(
|
public async showDialog(
|
||||||
@@ -50,9 +55,15 @@ export class DialogEnergyDeviceSettings
|
|||||||
this._energy_units = (
|
this._energy_units = (
|
||||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||||
).units;
|
).units;
|
||||||
|
this._power_units = (
|
||||||
|
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||||
|
).units;
|
||||||
this._excludeList = this._params.device_consumptions
|
this._excludeList = this._params.device_consumptions
|
||||||
.map((entry) => entry.stat_consumption)
|
.map((entry) => entry.stat_consumption)
|
||||||
.filter((id) => id !== this._device?.stat_consumption);
|
.filter((id) => id !== this._device?.stat_consumption);
|
||||||
|
this._excludeListPower = this._params.device_consumptions
|
||||||
|
.map((entry) => entry.stat_rate)
|
||||||
|
.filter((id) => id && id !== this._device?.stat_rate) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _computePossibleParents() {
|
private _computePossibleParents() {
|
||||||
@@ -93,8 +104,6 @@ export class DialogEnergyDeviceSettings
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-dialog
|
<ha-dialog
|
||||||
open
|
open
|
||||||
@@ -108,12 +117,6 @@ export class DialogEnergyDeviceSettings
|
|||||||
@closed=${this.closeDialog}
|
@closed=${this.closeDialog}
|
||||||
>
|
>
|
||||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||||
<div>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
|
||||||
{ unit: pickableUnit }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ha-statistic-picker
|
<ha-statistic-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -125,9 +128,28 @@ export class DialogEnergyDeviceSettings
|
|||||||
)}
|
)}
|
||||||
.excludeStatistics=${this._excludeList}
|
.excludeStatistics=${this._excludeList}
|
||||||
@value-changed=${this._statisticChanged}
|
@value-changed=${this._statisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
||||||
|
{ unit: this._energy_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
dialogInitialFocus
|
dialogInitialFocus
|
||||||
></ha-statistic-picker>
|
></ha-statistic-picker>
|
||||||
|
|
||||||
|
<ha-statistic-picker
|
||||||
|
.hass=${this.hass}
|
||||||
|
.includeUnitClass=${powerUnitClasses}
|
||||||
|
.value=${this._device?.stat_rate}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.device_consumption.dialog.device_consumption_power"
|
||||||
|
)}
|
||||||
|
.excludeStatistics=${this._excludeListPower}
|
||||||
|
@value-changed=${this._powerStatisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
||||||
|
{ unit: this._power_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
|
></ha-statistic-picker>
|
||||||
|
|
||||||
<ha-textfield
|
<ha-textfield
|
||||||
.label=${this.hass.localize(
|
.label=${this.hass.localize(
|
||||||
"ui.panel.config.energy.device_consumption.dialog.display_name"
|
"ui.panel.config.energy.device_consumption.dialog.display_name"
|
||||||
@@ -210,6 +232,20 @@ export class DialogEnergyDeviceSettings
|
|||||||
this._computePossibleParents();
|
this._computePossibleParents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||||
|
if (!this._device) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newDevice = {
|
||||||
|
...this._device,
|
||||||
|
stat_rate: ev.detail.value,
|
||||||
|
} as DeviceConsumptionEnergyPreference;
|
||||||
|
if (!newDevice.stat_rate) {
|
||||||
|
delete newDevice.stat_rate;
|
||||||
|
}
|
||||||
|
this._device = newDevice;
|
||||||
|
}
|
||||||
|
|
||||||
private _nameChanged(ev) {
|
private _nameChanged(ev) {
|
||||||
const newDevice = {
|
const newDevice = {
|
||||||
...this._device!,
|
...this._device!,
|
||||||
@@ -245,15 +281,19 @@ export class DialogEnergyDeviceSettings
|
|||||||
return [
|
return [
|
||||||
haStyleDialog,
|
haStyleDialog,
|
||||||
css`
|
css`
|
||||||
|
ha-statistic-picker {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--ha-space-2);
|
||||||
|
}
|
||||||
ha-statistic-picker {
|
ha-statistic-picker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
ha-select {
|
ha-select {
|
||||||
margin-top: 16px;
|
margin-top: var(--ha-space-4);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
ha-textfield {
|
ha-textfield {
|
||||||
margin-top: 16px;
|
margin-top: var(--ha-space-4);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ export class DialogEnergyGridFlowSettings
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
|
||||||
|
|
||||||
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
|
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
|
||||||
|
|
||||||
const externalSource =
|
const externalSource =
|
||||||
@@ -135,19 +133,11 @@ export class DialogEnergyGridFlowSettings
|
|||||||
@closed=${this.closeDialog}
|
@closed=${this.closeDialog}
|
||||||
>
|
>
|
||||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||||
<div>
|
<p>
|
||||||
<p>
|
${this.hass.localize(
|
||||||
${this.hass.localize(
|
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
|
||||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
|
)}
|
||||||
)}
|
</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
${this.hass.localize(
|
|
||||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
|
|
||||||
{ unit: pickableUnit }
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ha-statistic-picker
|
<ha-statistic-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -163,6 +153,10 @@ export class DialogEnergyGridFlowSettings
|
|||||||
)}
|
)}
|
||||||
.excludeStatistics=${this._excludeList}
|
.excludeStatistics=${this._excludeList}
|
||||||
@value-changed=${this._statisticChanged}
|
@value-changed=${this._statisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
|
||||||
|
{ unit: this._energy_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
dialogInitialFocus
|
dialogInitialFocus
|
||||||
></ha-statistic-picker>
|
></ha-statistic-picker>
|
||||||
|
|
||||||
@@ -361,6 +355,10 @@ export class DialogEnergyGridFlowSettings
|
|||||||
ha-dialog {
|
ha-dialog {
|
||||||
--mdc-dialog-max-width: 430px;
|
--mdc-dialog-max-width: 430px;
|
||||||
}
|
}
|
||||||
|
ha-statistic-picker {
|
||||||
|
display: block;
|
||||||
|
margin: var(--ha-space-4) 0;
|
||||||
|
}
|
||||||
ha-formfield {
|
ha-formfield {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { mdiTransmissionTower } 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 "../../../../components/entity/ha-statistic-picker";
|
||||||
|
import "../../../../components/ha-dialog";
|
||||||
|
import "../../../../components/ha-button";
|
||||||
|
import type { GridPowerSourceEnergyPreference } from "../../../../data/energy";
|
||||||
|
import { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||||
|
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 { EnergySettingsGridPowerDialogParams } from "./show-dialogs-energy";
|
||||||
|
|
||||||
|
const powerUnitClasses = ["power"];
|
||||||
|
|
||||||
|
@customElement("dialog-energy-grid-power-settings")
|
||||||
|
export class DialogEnergyGridPowerSettings
|
||||||
|
extends LitElement
|
||||||
|
implements HassDialog<EnergySettingsGridPowerDialogParams>
|
||||||
|
{
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _params?: EnergySettingsGridPowerDialogParams;
|
||||||
|
|
||||||
|
@state() private _source?: GridPowerSourceEnergyPreference;
|
||||||
|
|
||||||
|
@state() private _power_units?: string[];
|
||||||
|
|
||||||
|
@state() private _error?: string;
|
||||||
|
|
||||||
|
private _excludeListPower?: string[];
|
||||||
|
|
||||||
|
public async showDialog(
|
||||||
|
params: EnergySettingsGridPowerDialogParams
|
||||||
|
): Promise<void> {
|
||||||
|
this._params = params;
|
||||||
|
this._source = params.source ? { ...params.source } : { stat_rate: "" };
|
||||||
|
|
||||||
|
const initialSourceIdPower = this._source.stat_rate;
|
||||||
|
|
||||||
|
this._power_units = (
|
||||||
|
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||||
|
).units;
|
||||||
|
|
||||||
|
this._excludeListPower = [
|
||||||
|
...(this._params.grid_source?.power?.map((entry) => entry.stat_rate) ||
|
||||||
|
[]),
|
||||||
|
].filter((id) => id && id !== initialSourceIdPower) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog() {
|
||||||
|
this._params = undefined;
|
||||||
|
this._source = undefined;
|
||||||
|
this._error = undefined;
|
||||||
|
this._excludeListPower = undefined;
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._params || !this._source) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-dialog
|
||||||
|
open
|
||||||
|
.heading=${html`<ha-svg-icon
|
||||||
|
.path=${mdiTransmissionTower}
|
||||||
|
style="--mdc-icon-size: 32px;"
|
||||||
|
></ha-svg-icon
|
||||||
|
>${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.power_dialog.header"
|
||||||
|
)}`}
|
||||||
|
@closed=${this.closeDialog}
|
||||||
|
>
|
||||||
|
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||||
|
|
||||||
|
<ha-statistic-picker
|
||||||
|
.hass=${this.hass}
|
||||||
|
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||||
|
.includeUnitClass=${powerUnitClasses}
|
||||||
|
.value=${this._source.stat_rate}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.power_dialog.power_stat"
|
||||||
|
)}
|
||||||
|
.excludeStatistics=${this._excludeListPower}
|
||||||
|
@value-changed=${this._powerStatisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.grid.power_dialog.power_helper",
|
||||||
|
{ unit: this._power_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
|
dialogInitialFocus
|
||||||
|
></ha-statistic-picker>
|
||||||
|
|
||||||
|
<ha-button
|
||||||
|
appearance="plain"
|
||||||
|
@click=${this.closeDialog}
|
||||||
|
slot="primaryAction"
|
||||||
|
>
|
||||||
|
${this.hass.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button
|
||||||
|
@click=${this._save}
|
||||||
|
.disabled=${!this._source.stat_rate}
|
||||||
|
slot="primaryAction"
|
||||||
|
>
|
||||||
|
${this.hass.localize("ui.common.save")}
|
||||||
|
</ha-button>
|
||||||
|
</ha-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||||
|
this._source = {
|
||||||
|
...this._source!,
|
||||||
|
stat_rate: ev.detail.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _save() {
|
||||||
|
try {
|
||||||
|
await this._params!.saveCallback(this._source!);
|
||||||
|
this.closeDialog();
|
||||||
|
} catch (err: any) {
|
||||||
|
this._error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return [
|
||||||
|
haStyleDialog,
|
||||||
|
css`
|
||||||
|
ha-dialog {
|
||||||
|
--mdc-dialog-max-width: 430px;
|
||||||
|
}
|
||||||
|
ha-statistic-picker {
|
||||||
|
display: block;
|
||||||
|
margin: var(--ha-space-4) 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"dialog-energy-grid-power-settings": DialogEnergyGridPowerSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { brandsUrl } from "../../../../util/brands-url";
|
|||||||
import type { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
|
import type { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
|
||||||
|
|
||||||
const energyUnitClasses = ["energy"];
|
const energyUnitClasses = ["energy"];
|
||||||
|
const powerUnitClasses = ["power"];
|
||||||
|
|
||||||
@customElement("dialog-energy-solar-settings")
|
@customElement("dialog-energy-solar-settings")
|
||||||
export class DialogEnergySolarSettings
|
export class DialogEnergySolarSettings
|
||||||
@@ -46,10 +47,14 @@ export class DialogEnergySolarSettings
|
|||||||
|
|
||||||
@state() private _energy_units?: string[];
|
@state() private _energy_units?: string[];
|
||||||
|
|
||||||
|
@state() private _power_units?: string[];
|
||||||
|
|
||||||
@state() private _error?: string;
|
@state() private _error?: string;
|
||||||
|
|
||||||
private _excludeList?: string[];
|
private _excludeList?: string[];
|
||||||
|
|
||||||
|
private _excludeListPower?: string[];
|
||||||
|
|
||||||
public async showDialog(
|
public async showDialog(
|
||||||
params: EnergySettingsSolarDialogParams
|
params: EnergySettingsSolarDialogParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -62,9 +67,15 @@ export class DialogEnergySolarSettings
|
|||||||
this._energy_units = (
|
this._energy_units = (
|
||||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||||
).units;
|
).units;
|
||||||
|
this._power_units = (
|
||||||
|
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||||
|
).units;
|
||||||
this._excludeList = this._params.solar_sources
|
this._excludeList = this._params.solar_sources
|
||||||
.map((entry) => entry.stat_energy_from)
|
.map((entry) => entry.stat_energy_from)
|
||||||
.filter((id) => id !== this._source?.stat_energy_from);
|
.filter((id) => id !== this._source?.stat_energy_from);
|
||||||
|
this._excludeListPower = this._params.solar_sources
|
||||||
|
.map((entry) => entry.stat_rate)
|
||||||
|
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeDialog() {
|
public closeDialog() {
|
||||||
@@ -81,8 +92,6 @@ export class DialogEnergySolarSettings
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-dialog
|
<ha-dialog
|
||||||
open
|
open
|
||||||
@@ -94,12 +103,6 @@ export class DialogEnergySolarSettings
|
|||||||
@closed=${this.closeDialog}
|
@closed=${this.closeDialog}
|
||||||
>
|
>
|
||||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||||
<div>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
|
||||||
{ unit: pickableUnit }
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ha-statistic-picker
|
<ha-statistic-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@@ -111,9 +114,28 @@ export class DialogEnergySolarSettings
|
|||||||
)}
|
)}
|
||||||
.excludeStatistics=${this._excludeList}
|
.excludeStatistics=${this._excludeList}
|
||||||
@value-changed=${this._statisticChanged}
|
@value-changed=${this._statisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||||
|
{ unit: this._energy_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
dialogInitialFocus
|
dialogInitialFocus
|
||||||
></ha-statistic-picker>
|
></ha-statistic-picker>
|
||||||
|
|
||||||
|
<ha-statistic-picker
|
||||||
|
.hass=${this.hass}
|
||||||
|
.includeUnitClass=${powerUnitClasses}
|
||||||
|
.value=${this._source.stat_rate}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.solar.dialog.solar_production_power"
|
||||||
|
)}
|
||||||
|
.excludeStatistics=${this._excludeListPower}
|
||||||
|
@value-changed=${this._powerStatisticChanged}
|
||||||
|
.helper=${this.hass.localize(
|
||||||
|
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||||
|
{ unit: this._power_units?.join(", ") || "" }
|
||||||
|
)}
|
||||||
|
></ha-statistic-picker>
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
"ui.panel.config.energy.solar.dialog.solar_production_forecast"
|
"ui.panel.config.energy.solar.dialog.solar_production_forecast"
|
||||||
@@ -267,6 +289,10 @@ export class DialogEnergySolarSettings
|
|||||||
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||||
|
this._source = { ...this._source!, stat_rate: ev.detail.value };
|
||||||
|
}
|
||||||
|
|
||||||
private async _save() {
|
private async _save() {
|
||||||
try {
|
try {
|
||||||
if (!this._forecast) {
|
if (!this._forecast) {
|
||||||
@@ -287,6 +313,10 @@ export class DialogEnergySolarSettings
|
|||||||
ha-dialog {
|
ha-dialog {
|
||||||
--mdc-dialog-max-width: 430px;
|
--mdc-dialog-max-width: 430px;
|
||||||
}
|
}
|
||||||
|
ha-statistic-picker {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--ha-space-4);
|
||||||
|
}
|
||||||
img {
|
img {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
FlowFromGridSourceEnergyPreference,
|
FlowFromGridSourceEnergyPreference,
|
||||||
FlowToGridSourceEnergyPreference,
|
FlowToGridSourceEnergyPreference,
|
||||||
GasSourceTypeEnergyPreference,
|
GasSourceTypeEnergyPreference,
|
||||||
|
GridPowerSourceEnergyPreference,
|
||||||
GridSourceTypeEnergyPreference,
|
GridSourceTypeEnergyPreference,
|
||||||
SolarSourceTypeEnergyPreference,
|
SolarSourceTypeEnergyPreference,
|
||||||
WaterSourceTypeEnergyPreference,
|
WaterSourceTypeEnergyPreference,
|
||||||
@@ -41,6 +42,12 @@ export interface EnergySettingsGridFlowToDialogParams {
|
|||||||
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
|
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnergySettingsGridPowerDialogParams {
|
||||||
|
source?: GridPowerSourceEnergyPreference;
|
||||||
|
grid_source?: GridSourceTypeEnergyPreference;
|
||||||
|
saveCallback: (source: GridPowerSourceEnergyPreference) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnergySettingsSolarDialogParams {
|
export interface EnergySettingsSolarDialogParams {
|
||||||
info: EnergyInfo;
|
info: EnergyInfo;
|
||||||
source?: SolarSourceTypeEnergyPreference;
|
source?: SolarSourceTypeEnergyPreference;
|
||||||
@@ -152,3 +159,14 @@ export const showEnergySettingsGridFlowToDialog = (
|
|||||||
dialogParams: { ...dialogParams, direction: "to" },
|
dialogParams: { ...dialogParams, direction: "to" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const showEnergySettingsGridPowerDialog = (
|
||||||
|
element: HTMLElement,
|
||||||
|
dialogParams: EnergySettingsGridPowerDialogParams
|
||||||
|
): void => {
|
||||||
|
fireEvent(element, "show-dialog", {
|
||||||
|
dialogTag: "dialog-energy-grid-power-settings",
|
||||||
|
dialogImport: () => import("./dialog-energy-grid-power-settings"),
|
||||||
|
dialogParams: dialogParams,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -792,7 +792,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
|
|||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
reducedTouchTarget
|
reducedTouchTarget
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -634,7 +634,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
|||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
reducedTouchTarget
|
reducedTouchTarget
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -426,6 +426,10 @@ class DialogZHAReconfigureDevice extends LitElement {
|
|||||||
return [
|
return [
|
||||||
haStyleDialog,
|
haStyleDialog,
|
||||||
css`
|
css`
|
||||||
|
ha-dialog {
|
||||||
|
--mdc-dialog-max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3fr 1fr 2fr;
|
grid-template-columns: 3fr 1fr 2fr;
|
||||||
|
|||||||
@@ -360,6 +360,20 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hass.panels.home) {
|
||||||
|
result.push({
|
||||||
|
icon: this.hass.panels.home.icon || "mdi:home",
|
||||||
|
title: this.hass.localize("panel.home"),
|
||||||
|
show_in_sidebar: true,
|
||||||
|
mode: "storage",
|
||||||
|
url_path: "home",
|
||||||
|
filename: "",
|
||||||
|
default: false,
|
||||||
|
require_admin: false,
|
||||||
|
type: this._localizeType("built_in"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
result.push(
|
result.push(
|
||||||
...dashboards
|
...dashboards
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
@@ -470,13 +484,18 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _canDelete(urlPath: string) {
|
private _canDelete(urlPath: string) {
|
||||||
return !["lovelace", "energy", "light", "security", "climate"].includes(
|
return ![
|
||||||
urlPath
|
"lovelace",
|
||||||
);
|
"energy",
|
||||||
|
"light",
|
||||||
|
"security",
|
||||||
|
"climate",
|
||||||
|
"home",
|
||||||
|
].includes(urlPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _canEdit(urlPath: string) {
|
private _canEdit(urlPath: string) {
|
||||||
return !["light", "security", "climate"].includes(urlPath);
|
return !["light", "security", "climate", "home"].includes(urlPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleDelete = async (item: DataTableItem) => {
|
private _handleDelete = async (item: DataTableItem) => {
|
||||||
|
|||||||
@@ -473,7 +473,10 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
|||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
reducedTouchTarget
|
reducedTouchTarget
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -65,13 +65,15 @@ export default class HaScriptFieldRow extends LitElement {
|
|||||||
private _selectorRowElement?: HaAutomationRow;
|
private _selectorRowElement?: HaAutomationRow;
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
|
const hasSelector =
|
||||||
|
this.field.selector && typeof this.field.selector === "object";
|
||||||
return html`
|
return html`
|
||||||
<ha-card outlined>
|
<ha-card outlined>
|
||||||
<ha-automation-row
|
<ha-automation-row
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
@click=${this._toggleSidebar}
|
@click=${this._toggleSidebar}
|
||||||
.selected=${this._selected}
|
.selected=${this._selected}
|
||||||
left-chevron
|
.leftChevron=${hasSelector}
|
||||||
@toggle-collapsed=${this._toggleCollapse}
|
@toggle-collapsed=${this._toggleCollapse}
|
||||||
.collapsed=${this._collapsed}
|
.collapsed=${this._collapsed}
|
||||||
.highlight=${this.highlight}
|
.highlight=${this.highlight}
|
||||||
@@ -140,117 +142,124 @@ export default class HaScriptFieldRow extends LitElement {
|
|||||||
<slot name="icons" slot="icons"></slot>
|
<slot name="icons" slot="icons"></slot>
|
||||||
</ha-automation-row>
|
</ha-automation-row>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
<div
|
${hasSelector
|
||||||
class=${classMap({
|
? html`
|
||||||
"selector-row": true,
|
<div
|
||||||
"parent-selected": this._selected,
|
class=${classMap({
|
||||||
hidden: this._collapsed,
|
"selector-row": true,
|
||||||
})}
|
"parent-selected": this._selected,
|
||||||
>
|
hidden: this._collapsed,
|
||||||
<ha-card>
|
})}
|
||||||
<ha-automation-row
|
|
||||||
.selected=${this._selectorRowSelected}
|
|
||||||
@click=${this._toggleSelectorSidebar}
|
|
||||||
.collapsed=${this._selectorRowCollapsed}
|
|
||||||
@toggle-collapsed=${this._toggleSelectorRowCollapse}
|
|
||||||
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
|
||||||
Object.keys(this.field.selector)[0]
|
|
||||||
)}
|
|
||||||
.highlight=${this.highlight}
|
|
||||||
>
|
|
||||||
<h3 slot="header">
|
|
||||||
${this.hass.localize(
|
|
||||||
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
|
|
||||||
)}
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.script.editor.field.selector"
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<ha-md-button-menu
|
|
||||||
quick
|
|
||||||
slot="icons"
|
|
||||||
@click=${preventDefaultStopPropagation}
|
|
||||||
@keydown=${stopPropagation}
|
|
||||||
@closed=${stopPropagation}
|
|
||||||
positioning="fixed"
|
|
||||||
anchor-corner="end-end"
|
|
||||||
menu-corner="start-end"
|
|
||||||
>
|
>
|
||||||
<ha-icon-button
|
<ha-card>
|
||||||
slot="trigger"
|
<ha-automation-row
|
||||||
.label=${this.hass.localize("ui.common.menu")}
|
.selected=${this._selectorRowSelected}
|
||||||
.path=${mdiDotsVertical}
|
@click=${this._toggleSelectorSidebar}
|
||||||
></ha-icon-button>
|
.collapsed=${this._selectorRowCollapsed}
|
||||||
<ha-md-menu-item
|
@toggle-collapsed=${this._toggleSelectorRowCollapse}
|
||||||
.clickAction=${this._toggleYamlMode}
|
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||||
selector-row
|
Object.keys(this.field.selector)[0]
|
||||||
>
|
|
||||||
<ha-svg-icon
|
|
||||||
slot="start"
|
|
||||||
.path=${mdiPlaylistEdit}
|
|
||||||
></ha-svg-icon>
|
|
||||||
<div class="overflow-label">
|
|
||||||
${this.hass.localize(
|
|
||||||
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
|
|
||||||
)}
|
)}
|
||||||
<span
|
.highlight=${this.highlight}
|
||||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
>
|
||||||
></span>
|
<h3 slot="header">
|
||||||
</div>
|
${this.hass.localize(
|
||||||
</ha-md-menu-item>
|
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
|
||||||
<ha-md-menu-item
|
)}
|
||||||
.clickAction=${this._onDelete}
|
${this.hass.localize(
|
||||||
.disabled=${this.disabled}
|
"ui.panel.config.script.editor.field.selector"
|
||||||
class="warning"
|
)}
|
||||||
>
|
</h3>
|
||||||
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
|
<ha-md-button-menu
|
||||||
<div class="overflow-label">
|
quick
|
||||||
${this.hass.localize(
|
slot="icons"
|
||||||
"ui.panel.config.automation.editor.actions.delete"
|
@click=${preventDefaultStopPropagation}
|
||||||
)}
|
@keydown=${stopPropagation}
|
||||||
${!this.narrow
|
@closed=${stopPropagation}
|
||||||
? html`<span class="shortcut">
|
positioning="fixed"
|
||||||
|
anchor-corner="end-end"
|
||||||
|
menu-corner="start-end"
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
slot="trigger"
|
||||||
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
.path=${mdiDotsVertical}
|
||||||
|
></ha-icon-button>
|
||||||
|
<ha-md-menu-item
|
||||||
|
.clickAction=${this._toggleYamlMode}
|
||||||
|
selector-row
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${mdiPlaylistEdit}
|
||||||
|
></ha-svg-icon>
|
||||||
|
<div class="overflow-label">
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
>${isMac
|
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||||
? html`<ha-svg-icon
|
></span>
|
||||||
slot="start"
|
</div>
|
||||||
.path=${mdiAppleKeyboardCommand}
|
</ha-md-menu-item>
|
||||||
></ha-svg-icon>`
|
<ha-md-menu-item
|
||||||
: this.hass.localize(
|
.clickAction=${this._onDelete}
|
||||||
"ui.panel.config.automation.editor.ctrl"
|
.disabled=${this.disabled}
|
||||||
)}</span
|
class="warning"
|
||||||
>
|
>
|
||||||
<span>+</span>
|
<ha-svg-icon
|
||||||
<span
|
slot="start"
|
||||||
>${this.hass.localize(
|
.path=${mdiDelete}
|
||||||
"ui.panel.config.automation.editor.del"
|
></ha-svg-icon>
|
||||||
)}</span
|
<div class="overflow-label">
|
||||||
>
|
${this.hass.localize(
|
||||||
</span>`
|
"ui.panel.config.automation.editor.actions.delete"
|
||||||
: nothing}
|
)}
|
||||||
</div>
|
${!this.narrow
|
||||||
</ha-md-menu-item>
|
? html`<span class="shortcut">
|
||||||
</ha-md-button-menu>
|
<span
|
||||||
</ha-automation-row>
|
>${isMac
|
||||||
</ha-card>
|
? html`<ha-svg-icon
|
||||||
${typeof this.field.selector === "object" &&
|
slot="start"
|
||||||
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
.path=${mdiAppleKeyboardCommand}
|
||||||
Object.keys(this.field.selector)[0]
|
></ha-svg-icon>`
|
||||||
)
|
: this.hass.localize(
|
||||||
? html`
|
"ui.panel.config.automation.editor.ctrl"
|
||||||
<ha-script-field-selector-editor
|
)}</span
|
||||||
class=${this._selectorRowCollapsed ? "hidden" : ""}
|
>
|
||||||
.selected=${this._selectorRowSelected}
|
<span>+</span>
|
||||||
.hass=${this.hass}
|
<span
|
||||||
.field=${this.field}
|
>${this.hass.localize(
|
||||||
.disabled=${this.disabled}
|
"ui.panel.config.automation.editor.del"
|
||||||
indent
|
)}</span
|
||||||
@value-changed=${this._selectorValueChanged}
|
>
|
||||||
.narrow=${this.narrow}
|
</span>`
|
||||||
></ha-script-field-selector-editor>
|
: nothing}
|
||||||
`
|
</div>
|
||||||
: nothing}
|
</ha-md-menu-item>
|
||||||
</div>
|
</ha-md-button-menu>
|
||||||
|
</ha-automation-row>
|
||||||
|
</ha-card>
|
||||||
|
${typeof this.field.selector === "object" &&
|
||||||
|
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||||
|
Object.keys(this.field.selector)[0]
|
||||||
|
)
|
||||||
|
? html`
|
||||||
|
<ha-script-field-selector-editor
|
||||||
|
class=${this._selectorRowCollapsed ? "hidden" : ""}
|
||||||
|
.selected=${this._selectorRowSelected}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.field=${this.field}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
indent
|
||||||
|
@value-changed=${this._selectorValueChanged}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
></ha-script-field-selector-editor>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -458,7 +458,10 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
|||||||
.checked=${selected}
|
.checked=${selected}
|
||||||
.indeterminate=${partial}
|
.indeterminate=${partial}
|
||||||
></ha-checkbox>
|
></ha-checkbox>
|
||||||
<ha-label style=${color ? `--color: ${color}` : ""}>
|
<ha-label
|
||||||
|
style=${color ? `--color: ${color}` : ""}
|
||||||
|
.description=${label.description}
|
||||||
|
>
|
||||||
${label.icon
|
${label.icon
|
||||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export class HaConfigUsers extends LitElement {
|
|||||||
title: localize("ui.panel.config.users.picker.headers.username"),
|
title: localize("ui.panel.config.users.picker.headers.username"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
direction: "asc",
|
|
||||||
template: (user) => html`${user.username || "—"}`,
|
template: (user) => html`${user.username || "—"}`,
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
@@ -98,7 +97,6 @@ export class HaConfigUsers extends LitElement {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
groupable: true,
|
groupable: true,
|
||||||
direction: "asc",
|
|
||||||
},
|
},
|
||||||
is_active: {
|
is_active: {
|
||||||
title: this.hass.localize(
|
title: this.hass.localize(
|
||||||
|
|||||||
144
src/panels/home/ha-panel-home.ts
Normal file
144
src/panels/home/ha-panel-home.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||||
|
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 type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||||
|
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement("ha-panel-home")
|
||||||
|
class PanelHome extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public route?: Route;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public panel?: PanelInfo;
|
||||||
|
|
||||||
|
@state() private _lovelace?: Lovelace;
|
||||||
|
|
||||||
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
|
super.willUpdate(changedProps);
|
||||||
|
// Initial setup
|
||||||
|
if (!this.hasUpdated) {
|
||||||
|
this.hass.loadFragmentTranslation("lovelace");
|
||||||
|
this._setLovelace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changedProps.has("hass")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldHass = changedProps.get("hass") as this["hass"];
|
||||||
|
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||||
|
this._setLovelace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldHass && this.hass) {
|
||||||
|
// If the entity registry changed, ask the user if they want to refresh the config
|
||||||
|
if (
|
||||||
|
oldHass.entities !== this.hass.entities ||
|
||||||
|
oldHass.devices !== this.hass.devices ||
|
||||||
|
oldHass.areas !== this.hass.areas ||
|
||||||
|
oldHass.floors !== this.hass.floors
|
||||||
|
) {
|
||||||
|
if (this.hass.config.state === "RUNNING") {
|
||||||
|
this._debounceRegistriesChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If ha started, refresh the config
|
||||||
|
if (
|
||||||
|
this.hass.config.state === "RUNNING" &&
|
||||||
|
oldHass.config.state !== "RUNNING"
|
||||||
|
) {
|
||||||
|
this._setLovelace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _debounceRegistriesChanged = debounce(
|
||||||
|
() => this._registriesChanged(),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
private _registriesChanged = async () => {
|
||||||
|
this._setLovelace();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._lovelace) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<hui-root
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.lovelace=${this._lovelace}
|
||||||
|
.route=${this.route}
|
||||||
|
.panel=${this.panel}
|
||||||
|
></hui-root>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setLovelace() {
|
||||||
|
const config = await generateLovelaceDashboardStrategy(
|
||||||
|
HOME_LOVELACE_CONFIG,
|
||||||
|
this.hass
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deepEqual(config, this._lovelace?.config)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lovelace = {
|
||||||
|
config: config,
|
||||||
|
rawConfig: config,
|
||||||
|
editMode: false,
|
||||||
|
urlPath: "home",
|
||||||
|
mode: "generated",
|
||||||
|
locale: this.hass.locale,
|
||||||
|
enableFullEditMode: () => undefined,
|
||||||
|
saveConfig: async () => undefined,
|
||||||
|
deleteConfig: async () => undefined,
|
||||||
|
setEditMode: this._setEditMode,
|
||||||
|
showToast: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly styles: CSSResultGroup = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-panel-home": PanelHome;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
|||||||
import "../../../components/ha-svg-icon";
|
import "../../../components/ha-svg-icon";
|
||||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import type { HomeAssistant } from "../../../types";
|
||||||
import {
|
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||||
ConditionalListenerMixin,
|
|
||||||
setupMediaQueryListeners,
|
|
||||||
} from "../../../mixins/conditional-listener-mixin";
|
|
||||||
import { checkConditionsMet } from "../common/validate-condition";
|
import { checkConditionsMet } from "../common/validate-condition";
|
||||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||||
@@ -22,7 +19,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@customElement("hui-badge")
|
@customElement("hui-badge")
|
||||||
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||||
|
ReactiveElement
|
||||||
|
) {
|
||||||
@property({ type: Boolean }) public preview = false;
|
@property({ type: Boolean }) public preview = false;
|
||||||
|
|
||||||
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
|
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
|
||||||
@@ -53,7 +52,7 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
this._updateVisibility();
|
this._updateVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateElement(config: LovelaceBadgeConfig) {
|
protected _updateElement(config: LovelaceBadgeConfig) {
|
||||||
if (!this._element) {
|
if (!this._element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,22 +132,7 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupConditionalListeners() {
|
protected _updateVisibility(conditionsMet?: boolean) {
|
||||||
if (!this.config?.visibility || !this.hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupMediaQueryListeners(
|
|
||||||
this.config.visibility,
|
|
||||||
this.hass,
|
|
||||||
(unsub) => this.addConditionalListener(unsub),
|
|
||||||
(conditionsMet) => {
|
|
||||||
this._updateVisibility(conditionsMet);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _updateVisibility(ignoreConditions?: boolean) {
|
|
||||||
if (!this._element || !this.hass) {
|
if (!this._element || !this.hass) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,9 +153,9 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visible =
|
const visible =
|
||||||
ignoreConditions ||
|
conditionsMet ??
|
||||||
!this.config?.visibility ||
|
(!this.config?.visibility ||
|
||||||
checkConditionsMet(this.config.visibility, this.hass);
|
checkConditionsMet(this.config.visibility, this.hass));
|
||||||
this._setElementVisibility(visible);
|
this._setElementVisibility(visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
BarSeriesOption,
|
BarSeriesOption,
|
||||||
CallbackDataParams,
|
CallbackDataParams,
|
||||||
|
LineSeriesOption,
|
||||||
TopLevelFormatterParams,
|
TopLevelFormatterParams,
|
||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
|
import type { LineDataItemOption } from "echarts/types/src/chart/line/LineSeries";
|
||||||
import type { FrontendLocaleData } from "../../../../../data/translation";
|
import type { FrontendLocaleData } from "../../../../../data/translation";
|
||||||
import { formatNumber } from "../../../../../common/number/format_number";
|
import { formatNumber } from "../../../../../common/number/format_number";
|
||||||
import {
|
import {
|
||||||
@@ -170,11 +172,10 @@ function formatTooltip(
|
|||||||
compare
|
compare
|
||||||
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
|
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
|
||||||
: ""
|
: ""
|
||||||
}${formatTime(date, locale, config)} – ${formatTime(
|
}${formatTime(date, locale, config)}`;
|
||||||
addHours(date, 1),
|
if (params[0].componentSubType === "bar") {
|
||||||
locale,
|
period += ` – ${formatTime(addHours(date, 1), locale, config)}`;
|
||||||
config
|
}
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
|
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
|
||||||
|
|
||||||
@@ -281,6 +282,35 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fillLineGaps(datasets: LineSeriesOption[]) {
|
||||||
|
const buckets = Array.from(
|
||||||
|
new Set(
|
||||||
|
datasets
|
||||||
|
.map((dataset) =>
|
||||||
|
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
)
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
buckets.forEach((bucket, index) => {
|
||||||
|
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||||
|
const dataPoint = datasets[i].data![index];
|
||||||
|
const item: LineDataItemOption =
|
||||||
|
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||||
|
? dataPoint
|
||||||
|
: ({ value: dataPoint } as LineDataItemOption);
|
||||||
|
const x = item.value?.[0];
|
||||||
|
if (x === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Number(x) !== bucket) {
|
||||||
|
datasets[i].data?.splice(index, 0, [bucket, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return datasets;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCompareTransform(start: Date, compareStart?: Date) {
|
export function getCompareTransform(start: Date, compareStart?: Date) {
|
||||||
if (!compareStart) {
|
if (!compareStart) {
|
||||||
return (ts: Date) => ts;
|
return (ts: Date) => ts;
|
||||||
|
|||||||
335
src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts
Normal file
335
src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { endOfToday, isToday, startOfToday } from "date-fns";
|
||||||
|
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
|
import type { PropertyValues } from "lit";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { classMap } from "lit/directives/class-map";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { LineSeriesOption } from "echarts/charts";
|
||||||
|
import { graphic } from "echarts";
|
||||||
|
import "../../../../components/chart/ha-chart-base";
|
||||||
|
import "../../../../components/ha-card";
|
||||||
|
import type { EnergyData } from "../../../../data/energy";
|
||||||
|
import { getEnergyDataCollection } from "../../../../data/energy";
|
||||||
|
import type { StatisticValue } from "../../../../data/recorder";
|
||||||
|
import type { FrontendLocaleData } from "../../../../data/translation";
|
||||||
|
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||||
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import type { LovelaceCard } from "../../types";
|
||||||
|
import type { PowerSourcesGraphCardConfig } from "../types";
|
||||||
|
import { hasConfigChanged } from "../../common/has-changed";
|
||||||
|
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
|
||||||
|
import type { ECOption } from "../../../../resources/echarts/echarts";
|
||||||
|
import { hex2rgb } from "../../../../common/color/convert-color";
|
||||||
|
|
||||||
|
@customElement("hui-power-sources-graph-card")
|
||||||
|
export class HuiPowerSourcesGraphCard
|
||||||
|
extends SubscribeMixin(LitElement)
|
||||||
|
implements LovelaceCard
|
||||||
|
{
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _config?: PowerSourcesGraphCardConfig;
|
||||||
|
|
||||||
|
@state() private _chartData: LineSeriesOption[] = [];
|
||||||
|
|
||||||
|
@state() private _start = startOfToday();
|
||||||
|
|
||||||
|
@state() private _end = endOfToday();
|
||||||
|
|
||||||
|
@state() private _compareStart?: Date;
|
||||||
|
|
||||||
|
@state() private _compareEnd?: Date;
|
||||||
|
|
||||||
|
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||||
|
|
||||||
|
public hassSubscribe(): UnsubscribeFunc[] {
|
||||||
|
return [
|
||||||
|
getEnergyDataCollection(this.hass, {
|
||||||
|
key: this._config?.collection_key,
|
||||||
|
}).subscribe((data) => this._getStatistics(data)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCardSize(): Promise<number> | number {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setConfig(config: PowerSourcesGraphCardConfig): void {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
|
return (
|
||||||
|
hasConfigChanged(this, changedProps) ||
|
||||||
|
changedProps.size > 1 ||
|
||||||
|
!changedProps.has("hass")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this.hass || !this._config) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-card>
|
||||||
|
${this._config.title
|
||||||
|
? html`<h1 class="card-header">${this._config.title}</h1>`
|
||||||
|
: ""}
|
||||||
|
<div
|
||||||
|
class="content ${classMap({
|
||||||
|
"has-header": !!this._config.title,
|
||||||
|
})}"
|
||||||
|
>
|
||||||
|
<ha-chart-base
|
||||||
|
.hass=${this.hass}
|
||||||
|
.data=${this._chartData}
|
||||||
|
.options=${this._createOptions(
|
||||||
|
this._start,
|
||||||
|
this._end,
|
||||||
|
this.hass.locale,
|
||||||
|
this.hass.config,
|
||||||
|
this._compareStart,
|
||||||
|
this._compareEnd
|
||||||
|
)}
|
||||||
|
></ha-chart-base>
|
||||||
|
${!this._chartData.some((dataset) => dataset.data!.length)
|
||||||
|
? html`<div class="no-data">
|
||||||
|
${isToday(this._start)
|
||||||
|
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
||||||
|
: this.hass.localize(
|
||||||
|
"ui.panel.lovelace.cards.energy.no_data_period"
|
||||||
|
)}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createOptions = memoizeOne(
|
||||||
|
(
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
locale: FrontendLocaleData,
|
||||||
|
config: HassConfig,
|
||||||
|
compareStart?: Date,
|
||||||
|
compareEnd?: Date
|
||||||
|
): ECOption =>
|
||||||
|
getCommonOptions(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
locale,
|
||||||
|
config,
|
||||||
|
"kW",
|
||||||
|
compareStart,
|
||||||
|
compareEnd
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||||
|
const datasets: LineSeriesOption[] = [];
|
||||||
|
|
||||||
|
const statIds = {
|
||||||
|
solar: {
|
||||||
|
stats: [] as string[],
|
||||||
|
color: "--energy-solar-color",
|
||||||
|
name: this.hass.localize(
|
||||||
|
"ui.panel.lovelace.cards.energy.power_graph.solar"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
stats: [] as string[],
|
||||||
|
color: "--energy-grid-consumption-color",
|
||||||
|
name: this.hass.localize(
|
||||||
|
"ui.panel.lovelace.cards.energy.power_graph.grid"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
battery: {
|
||||||
|
stats: [] as string[],
|
||||||
|
color: "--energy-battery-out-color",
|
||||||
|
name: this.hass.localize(
|
||||||
|
"ui.panel.lovelace.cards.energy.power_graph.battery"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const computedStyles = getComputedStyle(this);
|
||||||
|
|
||||||
|
for (const source of energyData.prefs.energy_sources) {
|
||||||
|
if (source.type === "solar") {
|
||||||
|
if (source.stat_rate) {
|
||||||
|
statIds.solar.stats.push(source.stat_rate);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === "battery") {
|
||||||
|
if (source.stat_rate) {
|
||||||
|
statIds.battery.stats.push(source.stat_rate);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.type === "grid" && source.power) {
|
||||||
|
statIds.grid.stats.push(...source.power.map((p) => p.stat_rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commonSeriesOptions: LineSeriesOption = {
|
||||||
|
type: "line",
|
||||||
|
smooth: 0.4,
|
||||||
|
smoothMonotone: "x",
|
||||||
|
lineStyle: {
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(statIds).forEach((key, keyIndex) => {
|
||||||
|
if (statIds[key].stats.length) {
|
||||||
|
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
|
||||||
|
const rgb = hex2rgb(colorHex);
|
||||||
|
// Echarts is supposed to handle that but it is bugged when you use it together with stacking.
|
||||||
|
// The interpolation breaks the stacking, so this positive/negative is a workaround
|
||||||
|
const { positive, negative } = this._processData(
|
||||||
|
statIds[key].stats.map((id: string) => energyData.stats[id] ?? [])
|
||||||
|
);
|
||||||
|
datasets.push({
|
||||||
|
...commonSeriesOptions,
|
||||||
|
id: key,
|
||||||
|
name: statIds[key].name,
|
||||||
|
color: colorHex,
|
||||||
|
stack: "positive",
|
||||||
|
areaStyle: {
|
||||||
|
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
data: positive,
|
||||||
|
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
|
||||||
|
});
|
||||||
|
if (key !== "solar") {
|
||||||
|
datasets.push({
|
||||||
|
...commonSeriesOptions,
|
||||||
|
id: `${key}-negative`,
|
||||||
|
name: statIds[key].name,
|
||||||
|
color: colorHex,
|
||||||
|
stack: "negative",
|
||||||
|
areaStyle: {
|
||||||
|
color: new graphic.LinearGradient(0, 1, 0, 0, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
data: negative,
|
||||||
|
z: 4 - keyIndex, // draw in reverse order but above positive series
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._start = energyData.start;
|
||||||
|
this._end = energyData.end || endOfToday();
|
||||||
|
|
||||||
|
this._chartData = fillLineGaps(datasets);
|
||||||
|
|
||||||
|
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||||
|
this._chartData[0]?.data!.forEach((item, i) => {
|
||||||
|
// fillLineGaps ensures all datasets have the same x values
|
||||||
|
const x =
|
||||||
|
typeof item === "object" && "value" in item!
|
||||||
|
? item.value![0]
|
||||||
|
: item![0];
|
||||||
|
usageData[i] = [x, 0];
|
||||||
|
this._chartData.forEach((dataset) => {
|
||||||
|
const y =
|
||||||
|
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
|
||||||
|
? dataset.data![i].value![1]
|
||||||
|
: dataset.data![i]![1];
|
||||||
|
usageData[i]![1] += y as number;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._chartData.push({
|
||||||
|
...commonSeriesOptions,
|
||||||
|
id: "usage",
|
||||||
|
name: this.hass.localize(
|
||||||
|
"ui.panel.lovelace.cards.energy.power_graph.usage"
|
||||||
|
),
|
||||||
|
color: computedStyles.getPropertyValue("--primary-color"),
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
data: usageData,
|
||||||
|
z: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _processData(stats: StatisticValue[][]) {
|
||||||
|
const data: Record<number, number[]> = {};
|
||||||
|
stats.forEach((statSet) => {
|
||||||
|
statSet.forEach((point) => {
|
||||||
|
if (point.mean == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = (point.start + point.end) / 2;
|
||||||
|
data[x] = [...(data[x] ?? []), point.mean];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const positive: [number, number][] = [];
|
||||||
|
const negative: [number, number][] = [];
|
||||||
|
Object.entries(data).forEach(([x, y]) => {
|
||||||
|
const ts = Number(x);
|
||||||
|
const meanY = y.reduce((a, b) => a + b, 0) / y.length;
|
||||||
|
positive.push([ts, Math.max(0, meanY)]);
|
||||||
|
negative.push([ts, Math.min(0, meanY)]);
|
||||||
|
});
|
||||||
|
return { positive, negative };
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
ha-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: var(--ha-space-4);
|
||||||
|
}
|
||||||
|
.has-header {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20%;
|
||||||
|
margin-left: var(--ha-space-8);
|
||||||
|
margin-inline-start: var(--ha-space-8);
|
||||||
|
margin-inline-end: initial;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"hui-power-sources-graph-card": HuiPowerSourcesGraphCard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
|||||||
import "../../../components/ha-svg-icon";
|
import "../../../components/ha-svg-icon";
|
||||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import type { HomeAssistant } from "../../../types";
|
||||||
import {
|
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||||
ConditionalListenerMixin,
|
|
||||||
setupMediaQueryListeners,
|
|
||||||
} from "../../../mixins/conditional-listener-mixin";
|
|
||||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||||
import { computeCardSize } from "../common/compute-card-size";
|
import { computeCardSize } from "../common/compute-card-size";
|
||||||
import { checkConditionsMet } from "../common/validate-condition";
|
import { checkConditionsMet } from "../common/validate-condition";
|
||||||
@@ -24,7 +21,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@customElement("hui-card")
|
@customElement("hui-card")
|
||||||
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||||
|
ReactiveElement
|
||||||
|
) {
|
||||||
@property({ type: Boolean }) public preview = false;
|
@property({ type: Boolean }) public preview = false;
|
||||||
|
|
||||||
@property({ attribute: false }) public config?: LovelaceCardConfig;
|
@property({ attribute: false }) public config?: LovelaceCardConfig;
|
||||||
@@ -121,7 +120,7 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateElement(config: LovelaceCardConfig) {
|
protected _updateElement(config: LovelaceCardConfig) {
|
||||||
if (!this._element) {
|
if (!this._element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,22 +246,7 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupConditionalListeners() {
|
protected _updateVisibility(conditionsMet?: boolean) {
|
||||||
if (!this.config?.visibility || !this.hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupMediaQueryListeners(
|
|
||||||
this.config.visibility,
|
|
||||||
this.hass,
|
|
||||||
(unsub) => this.addConditionalListener(unsub),
|
|
||||||
(conditionsMet) => {
|
|
||||||
this._updateVisibility(conditionsMet);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _updateVisibility(ignoreConditions?: boolean) {
|
|
||||||
if (!this._element || !this.hass) {
|
if (!this._element || !this.hass) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -283,9 +267,9 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visible =
|
const visible =
|
||||||
ignoreConditions ||
|
conditionsMet ??
|
||||||
!this.config?.visibility ||
|
(!this.config?.visibility ||
|
||||||
checkConditionsMet(this.config.visibility, this.hass);
|
checkConditionsMet(this.config.visibility, this.hass));
|
||||||
this._setElementVisibility(visible);
|
this._setElementVisibility(visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,6 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
|
|||||||
this._errorCard.preview = this.preview;
|
this._errorCard.preview = this.preview;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changedProperties.has("layout")) {
|
|
||||||
this._cards.forEach((card) => {
|
|
||||||
card.layout = this.layout;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has("layout")) {
|
if (changedProperties.has("layout")) {
|
||||||
@@ -95,7 +90,6 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
|
|||||||
const element = document.createElement("hui-card");
|
const element = document.createElement("hui-card");
|
||||||
element.hass = this.hass;
|
element.hass = this.hass;
|
||||||
element.preview = this.preview;
|
element.preview = this.preview;
|
||||||
element.layout = this.layout;
|
|
||||||
element.config = cardConfig;
|
element.config = cardConfig;
|
||||||
element.load();
|
element.load();
|
||||||
return element;
|
return element;
|
||||||
|
|||||||
@@ -175,9 +175,9 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
|||||||
this._names = {};
|
this._names = {};
|
||||||
this._entities.forEach((config) => {
|
this._entities.forEach((config) => {
|
||||||
const stateObj = this.hass!.states[config.entity];
|
const stateObj = this.hass!.states[config.entity];
|
||||||
this._names[config.entity] = stateObj
|
this._names[config.entity] =
|
||||||
? computeLovelaceEntityName(this.hass!, stateObj, config.name)
|
computeLovelaceEntityName(this.hass!, stateObj, config.name) ||
|
||||||
: config.entity;
|
config.entity;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,11 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
|
|||||||
group_by_area?: boolean;
|
group_by_area?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
|
||||||
|
type: "power-sources-graph";
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
||||||
type: "entity-filter";
|
type: "entity-filter";
|
||||||
entities: (EntityFilterEntityConfig | string)[];
|
entities: (EntityFilterEntityConfig | string)[];
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ export const coordinates = (
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
maxDetails: number,
|
maxDetails: number,
|
||||||
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
|
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number },
|
||||||
|
useMean = false
|
||||||
) => {
|
) => {
|
||||||
history = history.filter((item) => !Number.isNaN(item[1]));
|
history = history.filter((item) => !Number.isNaN(item[1]));
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ export const coordinates = (
|
|||||||
history,
|
history,
|
||||||
maxDetails,
|
maxDetails,
|
||||||
limits?.minX,
|
limits?.minX,
|
||||||
limits?.maxX
|
limits?.maxX,
|
||||||
|
useMean
|
||||||
);
|
);
|
||||||
return calcPoints(sampledData, width, height, limits);
|
return calcPoints(sampledData, width, height, limits);
|
||||||
};
|
};
|
||||||
@@ -68,7 +70,8 @@ export const coordinatesMinimalResponseCompressedState = (
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
maxDetails: number,
|
maxDetails: number,
|
||||||
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
|
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number },
|
||||||
|
useMean = false
|
||||||
) => {
|
) => {
|
||||||
if (!history?.length) {
|
if (!history?.length) {
|
||||||
return { points: [], yAxisOrigin: 0 };
|
return { points: [], yAxisOrigin: 0 };
|
||||||
@@ -80,5 +83,5 @@ export const coordinatesMinimalResponseCompressedState = (
|
|||||||
item.lu * 1000,
|
item.lu * 1000,
|
||||||
Number(item.s),
|
Number(item.s),
|
||||||
]);
|
]);
|
||||||
return coordinates(mappedHistory, width, height, maxDetails, limits);
|
return coordinates(mappedHistory, width, height, maxDetails, limits, useMean);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
mdiAccount,
|
mdiAccount,
|
||||||
mdiAmpersand,
|
mdiAmpersand,
|
||||||
|
mdiCalendarClock,
|
||||||
mdiGateOr,
|
mdiGateOr,
|
||||||
mdiMapMarker,
|
mdiMapMarker,
|
||||||
mdiNotEqualVariant,
|
mdiNotEqualVariant,
|
||||||
@@ -15,6 +16,7 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
|||||||
numeric_state: mdiNumeric,
|
numeric_state: mdiNumeric,
|
||||||
state: mdiStateMachine,
|
state: mdiStateMachine,
|
||||||
screen: mdiResponsive,
|
screen: mdiResponsive,
|
||||||
|
time: mdiCalendarClock,
|
||||||
user: mdiAccount,
|
user: mdiAccount,
|
||||||
and: mdiAmpersand,
|
and: mdiAmpersand,
|
||||||
not: mdiNotEqualVariant,
|
not: mdiNotEqualVariant,
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { ensureArray } from "../../../common/array/ensure-array";
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
|
||||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
|
||||||
import { UNKNOWN } from "../../../data/entity";
|
import { UNKNOWN } from "../../../data/entity";
|
||||||
import { getUserPerson } from "../../../data/person";
|
import { getUserPerson } from "../../../data/person";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import { ensureArray } from "../../../common/array/ensure-array";
|
||||||
|
import {
|
||||||
|
checkTimeInRange,
|
||||||
|
isValidTimeString,
|
||||||
|
} from "../../../common/datetime/check_time";
|
||||||
|
import {
|
||||||
|
WEEKDAYS_SHORT,
|
||||||
|
type WeekdayShort,
|
||||||
|
} from "../../../common/datetime/weekday";
|
||||||
|
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||||
|
|
||||||
export type Condition =
|
export type Condition =
|
||||||
| LocationCondition
|
| LocationCondition
|
||||||
| NumericStateCondition
|
| NumericStateCondition
|
||||||
| StateCondition
|
| StateCondition
|
||||||
| ScreenCondition
|
| ScreenCondition
|
||||||
|
| TimeCondition
|
||||||
| UserCondition
|
| UserCondition
|
||||||
| OrCondition
|
| OrCondition
|
||||||
| AndCondition
|
| AndCondition
|
||||||
@@ -50,6 +58,13 @@ export interface ScreenCondition extends BaseCondition {
|
|||||||
media_query?: string;
|
media_query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimeCondition extends BaseCondition {
|
||||||
|
condition: "time";
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
weekdays?: WeekdayShort[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserCondition extends BaseCondition {
|
export interface UserCondition extends BaseCondition {
|
||||||
condition: "user";
|
condition: "user";
|
||||||
users?: string[];
|
users?: string[];
|
||||||
@@ -150,6 +165,13 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
|
|||||||
: false;
|
: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkTimeCondition(
|
||||||
|
condition: Omit<TimeCondition, "condition">,
|
||||||
|
hass: HomeAssistant
|
||||||
|
) {
|
||||||
|
return checkTimeInRange(hass, condition);
|
||||||
|
}
|
||||||
|
|
||||||
function checkLocationCondition(
|
function checkLocationCondition(
|
||||||
condition: LocationCondition,
|
condition: LocationCondition,
|
||||||
hass: HomeAssistant
|
hass: HomeAssistant
|
||||||
@@ -195,6 +217,8 @@ export function checkConditionsMet(
|
|||||||
return conditions.every((c) => {
|
return conditions.every((c) => {
|
||||||
if ("condition" in c) {
|
if ("condition" in c) {
|
||||||
switch (c.condition) {
|
switch (c.condition) {
|
||||||
|
case "time":
|
||||||
|
return checkTimeCondition(c, hass);
|
||||||
case "screen":
|
case "screen":
|
||||||
return checkScreenCondition(c, hass);
|
return checkScreenCondition(c, hass);
|
||||||
case "user":
|
case "user":
|
||||||
@@ -271,6 +295,35 @@ function validateScreenCondition(condition: ScreenCondition) {
|
|||||||
return condition.media_query != null;
|
return condition.media_query != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateTimeCondition(condition: TimeCondition) {
|
||||||
|
// Check if time strings are present and non-empty
|
||||||
|
const hasAfter = condition.after != null && condition.after !== "";
|
||||||
|
const hasBefore = condition.before != null && condition.before !== "";
|
||||||
|
const hasTime = hasAfter || hasBefore;
|
||||||
|
|
||||||
|
const hasWeekdays =
|
||||||
|
condition.weekdays != null && condition.weekdays.length > 0;
|
||||||
|
const weekdaysValid =
|
||||||
|
!hasWeekdays ||
|
||||||
|
condition.weekdays!.every((w: WeekdayShort) => WEEKDAYS_SHORT.includes(w));
|
||||||
|
|
||||||
|
// Validate time string formats if present
|
||||||
|
const timeStringsValid =
|
||||||
|
(!hasAfter || isValidTimeString(condition.after!)) &&
|
||||||
|
(!hasBefore || isValidTimeString(condition.before!));
|
||||||
|
|
||||||
|
// Prevent after and before being identical (creates zero-length interval)
|
||||||
|
const timeRangeValid =
|
||||||
|
!hasAfter || !hasBefore || condition.after !== condition.before;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasTime || hasWeekdays) &&
|
||||||
|
weekdaysValid &&
|
||||||
|
timeStringsValid &&
|
||||||
|
timeRangeValid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateUserCondition(condition: UserCondition) {
|
function validateUserCondition(condition: UserCondition) {
|
||||||
return condition.users != null;
|
return condition.users != null;
|
||||||
}
|
}
|
||||||
@@ -310,6 +363,8 @@ export function validateConditionalConfig(
|
|||||||
switch (c.condition) {
|
switch (c.condition) {
|
||||||
case "screen":
|
case "screen":
|
||||||
return validateScreenCondition(c);
|
return validateScreenCondition(c);
|
||||||
|
case "time":
|
||||||
|
return validateTimeCondition(c);
|
||||||
case "user":
|
case "user":
|
||||||
return validateUserCondition(c);
|
return validateUserCondition(c);
|
||||||
case "location":
|
case "location":
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import type { PropertyValues } from "lit";
|
|||||||
import { ReactiveElement } from "lit";
|
import { ReactiveElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import type { HomeAssistant } from "../../../types";
|
||||||
import {
|
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||||
ConditionalListenerMixin,
|
|
||||||
setupMediaQueryListeners,
|
|
||||||
} from "../../../mixins/conditional-listener-mixin";
|
|
||||||
import type { HuiCard } from "../cards/hui-card";
|
import type { HuiCard } from "../cards/hui-card";
|
||||||
import type { ConditionalCardConfig } from "../cards/types";
|
import type { ConditionalCardConfig } from "../cards/types";
|
||||||
import type { Condition } from "../common/validate-condition";
|
import type { Condition } from "../common/validate-condition";
|
||||||
@@ -22,9 +19,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@customElement("hui-conditional-base")
|
@customElement("hui-conditional-base")
|
||||||
export class HuiConditionalBase extends ConditionalListenerMixin(
|
export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||||
ReactiveElement
|
ConditionalCardConfig | ConditionalRowConfig
|
||||||
) {
|
>(ReactiveElement) {
|
||||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ type: Boolean }) public preview = false;
|
@property({ type: Boolean }) public preview = false;
|
||||||
@@ -73,18 +70,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter to supported conditions (those with 'condition' property)
|
||||||
const supportedConditions = this._config.conditions.filter(
|
const supportedConditions = this._config.conditions.filter(
|
||||||
(c) => "condition" in c
|
(c) => "condition" in c
|
||||||
) as Condition[];
|
) as Condition[];
|
||||||
|
|
||||||
setupMediaQueryListeners(
|
// Pass filtered conditions to parent implementation
|
||||||
supportedConditions,
|
super.setupConditionalListeners(supportedConditions);
|
||||||
this.hass,
|
|
||||||
(unsub) => this.addConditionalListener(unsub),
|
|
||||||
(conditionsMet) => {
|
|
||||||
this.setVisibility(conditionsMet);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected update(changed: PropertyValues): void {
|
protected update(changed: PropertyValues): void {
|
||||||
@@ -102,17 +94,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateVisibility() {
|
protected _updateVisibility(conditionsMet?: boolean) {
|
||||||
if (!this._element || !this.hass || !this._config) {
|
if (!this._element || !this.hass || !this._config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._element.preview = this.preview;
|
this._element.preview = this.preview;
|
||||||
|
|
||||||
const conditionMet = checkConditionsMet(
|
const conditionMet =
|
||||||
this._config!.conditions,
|
conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass);
|
||||||
this.hass!
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setVisibility(conditionMet);
|
this.setVisibility(conditionMet);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,29 +39,31 @@ export class HuiEntityEditor extends LitElement {
|
|||||||
private _renderItem(item: EntityConfig, index: number) {
|
private _renderItem(item: EntityConfig, index: number) {
|
||||||
const stateObj = this.hass.states[item.entity];
|
const stateObj = this.hass.states[item.entity];
|
||||||
|
|
||||||
const useDeviceName = entityUseDeviceName(
|
const useDeviceName =
|
||||||
stateObj,
|
stateObj &&
|
||||||
this.hass.entities,
|
entityUseDeviceName(stateObj, this.hass.entities, this.hass.devices);
|
||||||
this.hass.devices
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRTL = computeRTL(this.hass);
|
const isRTL = computeRTL(this.hass);
|
||||||
|
|
||||||
const primary =
|
const primary =
|
||||||
|
(stateObj &&
|
||||||
|
this.hass.formatEntityName(
|
||||||
|
stateObj,
|
||||||
|
useDeviceName ? { type: "device" } : { type: "entity" }
|
||||||
|
)) ||
|
||||||
|
item.entity;
|
||||||
|
|
||||||
|
const secondary =
|
||||||
|
stateObj &&
|
||||||
this.hass.formatEntityName(
|
this.hass.formatEntityName(
|
||||||
stateObj,
|
stateObj,
|
||||||
useDeviceName ? { type: "device" } : { type: "entity" }
|
useDeviceName
|
||||||
) || item.entity;
|
? [{ type: "area" }]
|
||||||
|
: [{ type: "area" }, { type: "device" }],
|
||||||
const secondary = this.hass.formatEntityName(
|
{
|
||||||
stateObj,
|
separator: isRTL ? " ◂ " : " ▸ ",
|
||||||
useDeviceName
|
}
|
||||||
? [{ type: "area" }]
|
);
|
||||||
: [{ type: "area" }, { type: "device" }],
|
|
||||||
{
|
|
||||||
separator: isRTL ? " ◂ " : " ▸ ",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-md-list-item class="item">
|
<ha-md-list-item class="item">
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { customElement, property } from "lit/decorators";
|
|||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import { ifDefined } from "lit/directives/if-defined";
|
import { ifDefined } from "lit/directives/if-defined";
|
||||||
import { DOMAINS_INPUT_ROW } from "../../../common/const";
|
import { DOMAINS_INPUT_ROW } from "../../../common/const";
|
||||||
|
import { uid } from "../../../common/util/uid";
|
||||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||||
import { toggleAttribute } from "../../../common/dom/toggle_attribute";
|
import { toggleAttribute } from "../../../common/dom/toggle_attribute";
|
||||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||||
|
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
|
||||||
import "../../../components/entity/state-badge";
|
import "../../../components/entity/state-badge";
|
||||||
import "../../../components/ha-relative-time";
|
import "../../../components/ha-relative-time";
|
||||||
|
import "../../../components/ha-tooltip";
|
||||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||||
import type { HomeAssistant } from "../../../types";
|
import type { HomeAssistant } from "../../../types";
|
||||||
import type { EntitiesCardEntityConfig } from "../cards/types";
|
import type { EntitiesCardEntityConfig } from "../cards/types";
|
||||||
@@ -36,6 +39,8 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
@property({ attribute: "catch-interaction", type: Boolean })
|
@property({ attribute: "catch-interaction", type: Boolean })
|
||||||
public catchInteraction?;
|
public catchInteraction?;
|
||||||
|
|
||||||
|
private _secondaryInfoElementId = "-" + uid();
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (!this.hass || !this.config) {
|
if (!this.hass || !this.config) {
|
||||||
return nothing;
|
return nothing;
|
||||||
@@ -100,7 +105,19 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
? stateObj.entity_id
|
? stateObj.entity_id
|
||||||
: this.config.secondary_info === "last-changed"
|
: this.config.secondary_info === "last-changed"
|
||||||
? html`
|
? html`
|
||||||
|
<ha-tooltip
|
||||||
|
for="last-changed${this
|
||||||
|
._secondaryInfoElementId}"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
${formatDateTimeWithSeconds(
|
||||||
|
new Date(stateObj.last_changed),
|
||||||
|
this.hass.locale,
|
||||||
|
this.hass.config
|
||||||
|
)}
|
||||||
|
</ha-tooltip>
|
||||||
<ha-relative-time
|
<ha-relative-time
|
||||||
|
id="last-changed${this._secondaryInfoElementId}"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.datetime=${stateObj.last_changed}
|
.datetime=${stateObj.last_changed}
|
||||||
capitalize
|
capitalize
|
||||||
@@ -108,7 +125,20 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
`
|
`
|
||||||
: this.config.secondary_info === "last-updated"
|
: this.config.secondary_info === "last-updated"
|
||||||
? html`
|
? html`
|
||||||
|
<ha-tooltip
|
||||||
|
for="last-updated${this
|
||||||
|
._secondaryInfoElementId}"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
${formatDateTimeWithSeconds(
|
||||||
|
new Date(stateObj.last_updated),
|
||||||
|
this.hass.locale,
|
||||||
|
this.hass.config
|
||||||
|
)}
|
||||||
|
</ha-tooltip>
|
||||||
<ha-relative-time
|
<ha-relative-time
|
||||||
|
id="last-updated${this
|
||||||
|
._secondaryInfoElementId}"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.datetime=${stateObj.last_updated}
|
.datetime=${stateObj.last_updated}
|
||||||
capitalize
|
capitalize
|
||||||
@@ -117,7 +147,22 @@ export class HuiGenericEntityRow extends LitElement {
|
|||||||
: this.config.secondary_info === "last-triggered"
|
: this.config.secondary_info === "last-triggered"
|
||||||
? stateObj.attributes.last_triggered
|
? stateObj.attributes.last_triggered
|
||||||
? html`
|
? html`
|
||||||
|
<ha-tooltip
|
||||||
|
for="last-triggered${this
|
||||||
|
._secondaryInfoElementId}"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
${formatDateTimeWithSeconds(
|
||||||
|
new Date(
|
||||||
|
stateObj.attributes.last_triggered
|
||||||
|
),
|
||||||
|
this.hass.locale,
|
||||||
|
this.hass.config
|
||||||
|
)}
|
||||||
|
</ha-tooltip>
|
||||||
<ha-relative-time
|
<ha-relative-time
|
||||||
|
id="last-triggered${this
|
||||||
|
._secondaryInfoElementId}"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.datetime=${stateObj.attributes
|
.datetime=${stateObj.attributes
|
||||||
.last_triggered}
|
.last_triggered}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user