mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-28 04:07:21 +00:00
Compare commits
135 Commits
number-ent
...
renovate/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa56d182d8 | ||
|
|
4df8501b20 | ||
|
|
539d0e443f | ||
|
|
9c9d274b5c | ||
|
|
d6e6bc0e80 | ||
|
|
530a70b168 | ||
|
|
23137500f8 | ||
|
|
63e7ed21a4 | ||
|
|
92611f46f4 | ||
|
|
9bc896241d | ||
|
|
2baafe620c | ||
|
|
ce52bbaf8c | ||
|
|
0b4b8d9082 | ||
|
|
bddbb773b7 | ||
|
|
d52e1e8835 | ||
|
|
0a9dccfd19 | ||
|
|
bfd78670cc | ||
|
|
11276af1a0 | ||
|
|
d7be46c00b | ||
|
|
94f32ce242 | ||
|
|
ef3e8186bc | ||
|
|
50fcf622aa | ||
|
|
77c2444be8 | ||
|
|
e5cb26cd3d | ||
|
|
2896519bfd | ||
|
|
0b6e35eb53 | ||
|
|
e80a855f87 | ||
|
|
7c88cf4e30 | ||
|
|
9001cd3e65 | ||
|
|
ca8923d8f4 | ||
|
|
e7e4407a09 | ||
|
|
3f0c9538bd | ||
|
|
5c3ccbfdad | ||
|
|
9710142c47 | ||
|
|
57640d17cd | ||
|
|
b5d93e7515 | ||
|
|
ca9b29d82a | ||
|
|
51efa4f61f | ||
|
|
a6c71719d1 | ||
|
|
44aa15809a | ||
|
|
0f99fcd58c | ||
|
|
0ff1b92fde | ||
|
|
98c9e0f2e4 | ||
|
|
493d650e15 | ||
|
|
c4e1ff0276 | ||
|
|
c8f5910d98 | ||
|
|
0ae49c7c3e | ||
|
|
d961aaace4 | ||
|
|
bd6bc25874 | ||
|
|
a45b07ea23 | ||
|
|
f7c446b257 | ||
|
|
71a29aa97d | ||
|
|
ae3a405a7b | ||
|
|
ebbcad812a | ||
|
|
d35a12d7e8 | ||
|
|
67e6dba85c | ||
|
|
fb666a7553 | ||
|
|
d767afb1e1 | ||
|
|
eb6191ab3a | ||
|
|
7fe9ae22f0 | ||
|
|
e3a1e5e0e3 | ||
|
|
01b8a5e01a | ||
|
|
a8ab035793 | ||
|
|
b36795ad3d | ||
|
|
ff0e173cfd | ||
|
|
11883525c2 | ||
|
|
f89f364add | ||
|
|
56391e32f2 | ||
|
|
b7f0f62949 | ||
|
|
ccc48d158a | ||
|
|
b11e787f09 | ||
|
|
cdb6562de8 | ||
|
|
d8e8c9aa02 | ||
|
|
be392be1e6 | ||
|
|
10dc432445 | ||
|
|
19187f887d | ||
|
|
dc76a42aaa | ||
|
|
1f2b8047a6 | ||
|
|
e8c9ed0528 | ||
|
|
c7ae78c02f | ||
|
|
dc8f1211e6 | ||
|
|
5c25a63ea5 | ||
|
|
c1787ab994 | ||
|
|
6fea535fdc | ||
|
|
e8cee84380 | ||
|
|
b4613edeb7 | ||
|
|
a8b6e5aa3d | ||
|
|
e842193cd6 | ||
|
|
bb0813333d | ||
|
|
ab4c6f80f4 | ||
|
|
89796e425a | ||
|
|
9c42c8bbc4 | ||
|
|
616237caee | ||
|
|
2d36a0d37f | ||
|
|
1ec432a20f | ||
|
|
afd91b2261 | ||
|
|
cdfb7f914f | ||
|
|
33b0897522 | ||
|
|
5f0cf1b522 | ||
|
|
afb2ad95a4 | ||
|
|
27beab3133 | ||
|
|
435c82489b | ||
|
|
3ba6bf272e | ||
|
|
eec99b2fa3 | ||
|
|
d23e45e410 | ||
|
|
3c82d12609 | ||
|
|
15d67997e7 | ||
|
|
a6dfcb3100 | ||
|
|
26c2369228 | ||
|
|
2eed446492 | ||
|
|
7ebdeab6b2 | ||
|
|
0c35278f51 | ||
|
|
561122f03d | ||
|
|
95311be034 | ||
|
|
1eda44a102 | ||
|
|
d76781eb91 | ||
|
|
82d44e051f | ||
|
|
fdc9f5a3b7 | ||
|
|
ee6c82aba9 | ||
|
|
11d3f5c2ba | ||
|
|
feb68ce373 | ||
|
|
7f9a9de157 | ||
|
|
8e1b6a3d3b | ||
|
|
6e6e5a53e2 | ||
|
|
0408734ec5 | ||
|
|
317519fc08 | ||
|
|
843d79eab4 | ||
|
|
165a757f06 | ||
|
|
ea8b730142 | ||
|
|
e88c97d625 | ||
|
|
7560988b76 | ||
|
|
eecd8077b6 | ||
|
|
cbab5c3f7b | ||
|
|
a5d27c8bb8 | ||
|
|
a6a340b5db |
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.11.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
|
||||
@@ -18,7 +18,6 @@ import { HaEventTrigger } from "../../../../src/panels/config/automation/trigger
|
||||
import { HaGeolocationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-geo_location";
|
||||
import { HaHassTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-homeassistant";
|
||||
import { HaTriggerList } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-list";
|
||||
import { HaMQTTTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-mqtt";
|
||||
import { HaNumericStateTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-numeric_state";
|
||||
import { HaPersistentNotificationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-persistent_notification";
|
||||
import { HaStateTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-state";
|
||||
@@ -38,11 +37,6 @@ const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
|
||||
triggers: [{ ...HaStateTrigger.defaultConfig }],
|
||||
},
|
||||
|
||||
{
|
||||
name: "MQTT",
|
||||
triggers: [{ ...HaMQTTTrigger.defaultConfig }],
|
||||
},
|
||||
|
||||
{
|
||||
name: "GeoLocation",
|
||||
triggers: [{ ...HaGeolocationTrigger.defaultConfig }],
|
||||
|
||||
48
package.json
48
package.json
@@ -28,13 +28,13 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.28.4",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@codemirror/autocomplete": "6.19.1",
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.0",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.5.11",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.6",
|
||||
"@codemirror/view": "6.38.8",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "6.18.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@fullcalendar/list": "6.1.19",
|
||||
"@fullcalendar/luxon3": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@home-assistant/webawesome": "3.0.0",
|
||||
"@home-assistant/webawesome": "3.0.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
@@ -96,10 +96,10 @@
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"app-datepicker": "5.1.1",
|
||||
"barcode-detector": "3.0.6",
|
||||
"color-name": "2.0.2",
|
||||
"barcode-detector": "3.0.8",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.46.0",
|
||||
"core-js": "3.47.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
"fuse.js": "7.1.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "6.0.2",
|
||||
"hls.js": "1.6.14",
|
||||
"hls.js": "1.6.15",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "10.7.18",
|
||||
@@ -122,7 +122,7 @@
|
||||
"lit": "3.3.1",
|
||||
"lit-html": "3.3.1",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.0",
|
||||
"marked": "17.0.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.3",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -139,12 +139,12 @@
|
||||
"vue": "2.7.16",
|
||||
"vue2-daterange-picker": "0.6.8",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-expiration": "7.3.0",
|
||||
"workbox-precaching": "7.3.0",
|
||||
"workbox-routing": "7.3.0",
|
||||
"workbox-strategies": "7.3.0",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0",
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -152,8 +152,8 @@
|
||||
"@babel/helper-define-polyfill-provider": "0.6.5",
|
||||
"@babel/plugin-transform-runtime": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.6",
|
||||
"@lokalise/node-api": "15.3.1",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.7",
|
||||
"@lokalise/node-api": "15.4.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.0.3",
|
||||
"@octokit/rest": "22.0.1",
|
||||
@@ -178,7 +178,7 @@
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.0.8",
|
||||
"@vitest/coverage-v8": "4.0.13",
|
||||
"babel-loader": "10.0.0",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
@@ -194,32 +194,32 @@
|
||||
"eslint-plugin-wc": "3.0.2",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.2",
|
||||
"glob": "12.0.0",
|
||||
"glob": "13.0.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "27.1.0",
|
||||
"jsdom": "27.2.0",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.2.6",
|
||||
"lint-staged": "16.2.7",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
"map-stream": "0.0.7",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"rspack-manifest-plugin": "5.1.0",
|
||||
"rspack-manifest-plugin": "5.2.0",
|
||||
"serve": "14.2.5",
|
||||
"sinon": "21.0.0",
|
||||
"tar": "7.5.2",
|
||||
"terser-webpack-plugin": "5.3.14",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.3",
|
||||
"typescript-eslint": "8.48.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.0.8",
|
||||
"vitest": "4.0.13",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||
@@ -236,7 +236,7 @@
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.11.0",
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"volta": {
|
||||
"node": "22.21.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuthData } from "home-assistant-js-websocket";
|
||||
import { extractSearchParam } from "../url/search-params";
|
||||
import { hassUrl } from "../../data/auth";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -30,7 +31,11 @@ export function askWrite() {
|
||||
export function saveTokens(tokens: AuthData | null) {
|
||||
tokenCache.tokens = tokens;
|
||||
|
||||
if (!tokenCache.writeEnabled && extractSearchParam("storeToken") === "true") {
|
||||
if (
|
||||
!tokenCache.writeEnabled &&
|
||||
(extractSearchParam("storeToken") === "true" ||
|
||||
hassUrl !== `${location.protocol}//${location.host}`)
|
||||
) {
|
||||
tokenCache.writeEnabled = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +1,16 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
export const COLORS = [
|
||||
"#4269d0",
|
||||
"#f4bd4a",
|
||||
"#ff725c",
|
||||
"#6cc5b0",
|
||||
"#a463f2",
|
||||
"#ff8ab7",
|
||||
"#9c6b4e",
|
||||
"#97bbf5",
|
||||
"#01ab63",
|
||||
"#094bad",
|
||||
"#c99000",
|
||||
"#d84f3e",
|
||||
"#49a28f",
|
||||
"#048732",
|
||||
"#d96895",
|
||||
"#8043ce",
|
||||
"#7599d1",
|
||||
"#7a4c31",
|
||||
"#6989f4",
|
||||
"#ffd444",
|
||||
"#ff957c",
|
||||
"#8fe9d3",
|
||||
"#62cc71",
|
||||
"#ffadda",
|
||||
"#c884ff",
|
||||
"#badeff",
|
||||
"#bf8b6d",
|
||||
"#927acc",
|
||||
"#97ee3f",
|
||||
"#bf3947",
|
||||
"#9f5b00",
|
||||
"#f48758",
|
||||
"#8caed6",
|
||||
"#f2b94f",
|
||||
"#eff26e",
|
||||
"#e43872",
|
||||
"#d9b100",
|
||||
"#9d7a00",
|
||||
"#698cff",
|
||||
"#00d27e",
|
||||
"#d06800",
|
||||
"#009f82",
|
||||
"#c49200",
|
||||
"#cbe8ff",
|
||||
"#fecddf",
|
||||
"#c27eb6",
|
||||
"#8cd2ce",
|
||||
"#c4b8d9",
|
||||
"#f883b0",
|
||||
"#a49100",
|
||||
"#f48800",
|
||||
"#27d0df",
|
||||
"#a04a9b",
|
||||
];
|
||||
// Total number of colors defined in CSS variables (--color-1 through --color-54)
|
||||
export const COLORS_COUNT = 54;
|
||||
|
||||
export function getColorByIndex(index: number) {
|
||||
return COLORS[index % COLORS.length];
|
||||
export function getColorByIndex(
|
||||
index: number,
|
||||
style: CSSStyleDeclaration
|
||||
): string {
|
||||
// Wrap around using modulo to support unlimited indices
|
||||
const colorIndex = (index % COLORS_COUNT) + 1;
|
||||
return style.getPropertyValue(`--color-${colorIndex}`);
|
||||
}
|
||||
|
||||
export function getGraphColorByIndex(
|
||||
@@ -68,15 +20,19 @@ export function getGraphColorByIndex(
|
||||
// The CSS vars for the colors use range 1..n, so we need to adjust the index from the internal 0..n color index range.
|
||||
const themeColor =
|
||||
style.getPropertyValue(`--graph-color-${index + 1}`) ||
|
||||
getColorByIndex(index);
|
||||
getColorByIndex(index, style);
|
||||
return theme2hex(themeColor);
|
||||
}
|
||||
|
||||
export const getAllGraphColors = memoizeOne(
|
||||
(style: CSSStyleDeclaration) =>
|
||||
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
|
||||
Array.from({ length: COLORS_COUNT }, (_, index) =>
|
||||
getGraphColorByIndex(index, style)
|
||||
),
|
||||
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
|
||||
// this is not ideal, but we need to memoize the colors
|
||||
newArgs[0].getPropertyValue("--graph-color-1") ===
|
||||
lastArgs[0].getPropertyValue("--graph-color-1")
|
||||
lastArgs[0].getPropertyValue("--graph-color-1") &&
|
||||
newArgs[0].getPropertyValue("--color-1") ===
|
||||
lastArgs[0].getPropertyValue("--color-1")
|
||||
);
|
||||
|
||||
@@ -279,6 +279,7 @@ export class HaSankeyChart extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
background: var(--ha-card-background, var(--card-background-color));
|
||||
}
|
||||
ha-chart-base {
|
||||
|
||||
@@ -838,10 +838,10 @@ export class HaDataTable extends LitElement {
|
||||
} else if (this.sortDirection === "asc") {
|
||||
this.sortDirection = "desc";
|
||||
} else {
|
||||
this.sortDirection = null;
|
||||
this.sortDirection = "asc";
|
||||
}
|
||||
|
||||
this.sortColumn = this.sortDirection === null ? undefined : columnId;
|
||||
this.sortColumn = columnId;
|
||||
|
||||
fireEvent(this, "sorting-changed", {
|
||||
column: columnId,
|
||||
|
||||
@@ -202,6 +202,7 @@ export class HaControlSelect extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border-radius: var(--control-select-border-radius);
|
||||
}
|
||||
:host([vertical]) {
|
||||
width: var(--control-select-thickness);
|
||||
@@ -211,7 +212,6 @@ export class HaControlSelect extends LitElement {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: var(--control-select-border-radius);
|
||||
transform: translateZ(0);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
mdiHomeFloor3,
|
||||
mdiHomeFloorNegative1,
|
||||
} from "@mdi/js";
|
||||
import { LitElement, html } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||
import "./ha-icon";
|
||||
@@ -48,7 +48,7 @@ export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
|
||||
|
||||
@customElement("ha-floor-icon")
|
||||
export class HaFloorIcon extends LitElement {
|
||||
@property({ attribute: false }) public floor!: Pick<
|
||||
@property({ attribute: false }) public floor?: Pick<
|
||||
FloorRegistryEntry,
|
||||
"icon" | "level"
|
||||
>;
|
||||
@@ -56,6 +56,9 @@ export class HaFloorIcon extends LitElement {
|
||||
@property() public icon?: string;
|
||||
|
||||
protected render() {
|
||||
if (!this.floor) {
|
||||
return nothing;
|
||||
}
|
||||
if (this.floor.icon) {
|
||||
return html`<ha-icon .icon=${this.floor.icon}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
return this._getLabelsMemoized(
|
||||
this.hass,
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labels,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
|
||||
@@ -81,6 +81,7 @@ class HaLabel extends LitElement {
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
|
||||
@@ -192,7 +192,7 @@ export class HaPickerComboBox extends LitElement {
|
||||
@focus=${this._focusList}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer> `;
|
||||
</lit-virtualizer>`;
|
||||
}
|
||||
|
||||
private _renderSectionButtons() {
|
||||
|
||||
28
src/components/ha-section-title.ts
Normal file
28
src/components/ha-section-title.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-section-title")
|
||||
class HaSectionTitle extends LitElement {
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-section-title": HaSectionTitle;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NumberSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../entity/ha-entity-picker";
|
||||
import "../ha-button-toggle-group";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../ha-textfield";
|
||||
@@ -18,7 +15,7 @@ export class HaNumberSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selector!: NumberSelector;
|
||||
|
||||
@property({ type: Number }) public value?: number | string;
|
||||
@property({ type: Number }) public value?: number;
|
||||
|
||||
@property({ type: Number }) public placeholder?: number;
|
||||
|
||||
@@ -33,30 +30,13 @@ export class HaNumberSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _mode: "number" | "entity" = "number";
|
||||
|
||||
private _valueStr = "";
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
if (
|
||||
this.selector.number?.entity?.domains.length &&
|
||||
typeof this.value === "string" &&
|
||||
this.selector.number?.entity?.domains.some((domain) =>
|
||||
(this.value as string).startsWith(`${domain}.`)
|
||||
)
|
||||
) {
|
||||
this._mode = "entity";
|
||||
}
|
||||
}
|
||||
if (changedProps.has("value")) {
|
||||
if (this._valueStr === "" || this.value !== Number(this._valueStr)) {
|
||||
this._valueStr =
|
||||
this.value == null ||
|
||||
typeof this.value === "string" ||
|
||||
isNaN(this.value)
|
||||
? ""
|
||||
: this.value.toString();
|
||||
this.value == null || isNaN(this.value) ? "" : this.value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,8 +47,6 @@ export class HaNumberSelector extends LitElement {
|
||||
this.selector.number?.min === undefined ||
|
||||
this.selector.number?.max === undefined;
|
||||
|
||||
const multiMode = Boolean(this.selector.number?.entity?.domains.length);
|
||||
|
||||
let sliderStep;
|
||||
|
||||
if (!isBox) {
|
||||
@@ -94,73 +72,51 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.label && !isBox && !multiMode
|
||||
${this.label && !isBox
|
||||
? html`${this.label}${this.required ? "*" : ""}`
|
||||
: nothing}
|
||||
${multiMode
|
||||
? html`<div class="multi-header">
|
||||
<span>${this.label}${this.required ? "*" : ""}</span>
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
.buttons=${this._toggleButtons(this.hass.localize)}
|
||||
.active=${this._mode}
|
||||
@value-changed=${this._modeChanged}
|
||||
></ha-button-toggle-group>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="input">
|
||||
${multiMode && this._mode === "entity"
|
||||
? html`<ha-entity-picker
|
||||
.hass=${this.hass}
|
||||
.includeDomains=${this.selector.number!.entity!.domains}
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-entity-picker>`
|
||||
: html`${!isBox
|
||||
? html`
|
||||
<ha-slider
|
||||
labeled
|
||||
.min=${this.selector.number!.min}
|
||||
.max=${this.selector.number!.max}
|
||||
.value=${this.value}
|
||||
.step=${sliderStep}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
@change=${this._handleSliderChange}
|
||||
.withMarkers=${this.selector.number?.slider_ticks ||
|
||||
false}
|
||||
>
|
||||
</ha-slider>
|
||||
`
|
||||
: nothing}
|
||||
<ha-textfield
|
||||
.inputMode=${this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
class=${classMap({ single: isBox })}
|
||||
.min=${this.selector.number?.min}
|
||||
.max=${this.selector.number?.max}
|
||||
.value=${this._valueStr ?? ""}
|
||||
.step=${this.selector.number?.step ?? 1}
|
||||
helperPersistent
|
||||
.helper=${isBox ? this.helper : undefined}
|
||||
${!isBox
|
||||
? html`
|
||||
<ha-slider
|
||||
labeled
|
||||
.min=${this.selector.number!.min}
|
||||
.max=${this.selector.number!.max}
|
||||
.value=${this.value}
|
||||
.step=${sliderStep}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.suffix=${unit}
|
||||
type="number"
|
||||
autoValidate
|
||||
?no-spinner=${!isBox}
|
||||
@input=${this._handleInputChange}
|
||||
@change=${this._handleSliderChange}
|
||||
.withMarkers=${this.selector.number?.slider_ticks || false}
|
||||
>
|
||||
</ha-textfield>`}
|
||||
</ha-slider>
|
||||
`
|
||||
: nothing}
|
||||
<ha-textfield
|
||||
.inputMode=${this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${this.placeholder}
|
||||
class=${classMap({ single: isBox })}
|
||||
.min=${this.selector.number?.min}
|
||||
.max=${this.selector.number?.max}
|
||||
.value=${this._valueStr ?? ""}
|
||||
.step=${this.selector.number?.step ?? 1}
|
||||
helperPersistent
|
||||
.helper=${isBox ? this.helper : undefined}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.suffix=${unit}
|
||||
type="number"
|
||||
autoValidate
|
||||
?no-spinner=${!isBox}
|
||||
@input=${this._handleInputChange}
|
||||
>
|
||||
</ha-textfield>
|
||||
</div>
|
||||
${!isBox && !(multiMode && this._mode === "entity") && this.helper
|
||||
${!isBox && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
@@ -168,22 +124,6 @@ export class HaNumberSelector extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleButtons = memoizeOne((localize: HomeAssistant["localize"]) => [
|
||||
{
|
||||
label: localize("ui.components.selectors.number.value"),
|
||||
value: "number",
|
||||
},
|
||||
{
|
||||
label: localize("ui.components.selectors.number.entity_value"),
|
||||
value: "entity",
|
||||
},
|
||||
]);
|
||||
|
||||
private _modeChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
this._mode = ev.detail?.value || ev.target.value;
|
||||
}
|
||||
|
||||
private _handleInputChange(ev) {
|
||||
ev.stopPropagation();
|
||||
this._valueStr = ev.target.value;
|
||||
@@ -215,32 +155,17 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
ha-slider {
|
||||
flex: 1;
|
||||
margin-right: var(--ha-space-4);
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
ha-textfield {
|
||||
--ha-textfield-input-width: 40px;
|
||||
}
|
||||
.multi-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.single {
|
||||
--ha-textfield-input-width: unset;
|
||||
flex: 1;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-button-toggle-group {
|
||||
display: block;
|
||||
justify-self: end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -465,10 +465,16 @@ export class HaServiceControl extends LitElement {
|
||||
? computeObjectId(this._value.action)
|
||||
: undefined;
|
||||
|
||||
const descriptionPlaceholders =
|
||||
domain && serviceName
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined;
|
||||
|
||||
const description =
|
||||
(serviceName &&
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.description`
|
||||
`component.${domain}.services.${serviceName}.description`,
|
||||
descriptionPlaceholders
|
||||
)) ||
|
||||
serviceData?.description;
|
||||
|
||||
@@ -537,7 +543,8 @@ export class HaServiceControl extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.value=${this._value?.data?.entity_id}
|
||||
.label=${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.entity_id.description`
|
||||
`component.${domain}.services.${serviceName}.fields.entity_id.description`,
|
||||
descriptionPlaceholders
|
||||
) || entityId.description}
|
||||
@value-changed=${this._entityPicked}
|
||||
allow-custom-entity
|
||||
@@ -575,7 +582,8 @@ export class HaServiceControl extends LitElement {
|
||||
left-chevron
|
||||
.expanded=${!dataField.collapsed}
|
||||
.header=${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`
|
||||
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`,
|
||||
descriptionPlaceholders
|
||||
) ||
|
||||
dataField.name ||
|
||||
dataField.key}
|
||||
@@ -611,7 +619,10 @@ export class HaServiceControl extends LitElement {
|
||||
serviceName: string | undefined
|
||||
) {
|
||||
return this.hass!.localize(
|
||||
`component.${domain}.services.${serviceName}.sections.${dataField.key}.description`
|
||||
`component.${domain}.services.${serviceName}.sections.${dataField.key}.description`,
|
||||
domain && serviceName
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,6 +669,10 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
|
||||
const showOptional = showOptionalToggle(dataField);
|
||||
const descriptionPlaceholders =
|
||||
domain && serviceName
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined;
|
||||
|
||||
return dataField.selector &&
|
||||
(!dataField.advanced ||
|
||||
@@ -679,7 +694,8 @@ export class HaServiceControl extends LitElement {
|
||||
></ha-checkbox>`}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`
|
||||
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`,
|
||||
descriptionPlaceholders
|
||||
) ||
|
||||
dataField.name ||
|
||||
dataField.key}</span
|
||||
@@ -689,7 +705,8 @@ export class HaServiceControl extends LitElement {
|
||||
breaks
|
||||
allow-svg
|
||||
.content=${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
|
||||
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`,
|
||||
descriptionPlaceholders
|
||||
) || dataField?.description}
|
||||
></ha-markdown>
|
||||
</span>
|
||||
|
||||
@@ -92,8 +92,14 @@ class HaServicePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const descriptionPlaceholders =
|
||||
this.hass.services[domain][service].description_placeholders;
|
||||
|
||||
const serviceName =
|
||||
localize(`component.${domain}.services.${service}.name`) ||
|
||||
localize(
|
||||
`component.${domain}.services.${service}.name`,
|
||||
descriptionPlaceholders
|
||||
) ||
|
||||
services[domain][service].name ||
|
||||
service;
|
||||
|
||||
@@ -163,16 +169,21 @@ class HaServicePicker extends LitElement {
|
||||
const serviceId = `${domain}.${service}`;
|
||||
const domainName = domainToName(localize, domain);
|
||||
|
||||
const descriptionPlaceholders =
|
||||
this.hass.services[domain][service].description_placeholders;
|
||||
|
||||
const name =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name`
|
||||
`component.${domain}.services.${service}.name`,
|
||||
descriptionPlaceholders
|
||||
) ||
|
||||
services[domain][service].name ||
|
||||
service;
|
||||
|
||||
const description =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.description`
|
||||
`component.${domain}.services.${service}.description`,
|
||||
descriptionPlaceholders
|
||||
) ||
|
||||
services[domain][service].description ||
|
||||
"";
|
||||
|
||||
@@ -197,6 +197,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _mouseLeaveTimeout?: number;
|
||||
|
||||
private _touchendTimeout?: number;
|
||||
|
||||
private _tooltipHideTimeout?: number;
|
||||
|
||||
private _recentKeydownActiveUntil = 0;
|
||||
@@ -237,6 +239,18 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
];
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// clear timeouts
|
||||
clearTimeout(this._mouseLeaveTimeout);
|
||||
clearTimeout(this._tooltipHideTimeout);
|
||||
clearTimeout(this._touchendTimeout);
|
||||
// set undefined values
|
||||
this._mouseLeaveTimeout = undefined;
|
||||
this._tooltipHideTimeout = undefined;
|
||||
this._touchendTimeout = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
@@ -406,6 +420,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
class="ha-scrollbar"
|
||||
@focusin=${this._listboxFocusIn}
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@touchend=${this._listboxTouchend}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
>
|
||||
@@ -620,6 +635,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
this._hideTooltip();
|
||||
}
|
||||
|
||||
private _listboxTouchend() {
|
||||
clearTimeout(this._touchendTimeout);
|
||||
this._touchendTimeout = window.setTimeout(() => {
|
||||
// Allow 1 second for users to read the tooltip on touch devices
|
||||
this._hideTooltip();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@eventOptions({
|
||||
passive: true,
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetType,
|
||||
type TargetTypeFloorless,
|
||||
} from "../data/target";
|
||||
@@ -47,7 +48,6 @@ import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@@ -634,35 +634,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
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,
|
||||
@@ -686,7 +657,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = this._getRowType(firstItem as PickerComboBoxItem);
|
||||
const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
|
||||
const translationType:
|
||||
| "areas"
|
||||
| "entities"
|
||||
@@ -858,7 +829,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass,
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labelRegistry,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -974,7 +948,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const type = this._getRowType(item);
|
||||
const type = getTargetComboBoxItemType(item);
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/dialog/dialog";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
@@ -7,8 +9,6 @@ import {
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import "@home-assistant/webawesome/dist/components/dialog/dialog";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -53,6 +53,7 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
|
||||
*
|
||||
* @attr {boolean} open - Controls the dialog open state.
|
||||
* @attr {("alert"|"standard")} type - Dialog type. Defaults to "standard".
|
||||
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
|
||||
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
|
||||
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
|
||||
@@ -84,6 +85,9 @@ export class HaWaDialog extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
|
||||
@property({ reflect: true })
|
||||
public type: "alert" | "standard" = "standard";
|
||||
|
||||
@property({ type: String, reflect: true, attribute: "width" })
|
||||
public width: DialogWidth = "medium";
|
||||
|
||||
@@ -172,7 +176,9 @@ export class HaWaDialog extends LitElement {
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
|
||||
requestAnimationFrame(() => {
|
||||
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
private _handleAfterShow = () => {
|
||||
@@ -198,18 +204,7 @@ export class HaWaDialog extends LitElement {
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
wa-dialog {
|
||||
--full-width: var(
|
||||
--ha-dialog-width-full,
|
||||
min(
|
||||
95vw,
|
||||
calc(
|
||||
100vw - var(--safe-area-inset-left, var(--ha-space-0)) - var(
|
||||
--safe-area-inset-right,
|
||||
var(--ha-space-0)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
--full-width: var(--ha-dialog-width-full, min(95vw, var(--safe-width)));
|
||||
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
|
||||
--spacing: var(--dialog-content-padding, var(--ha-space-6));
|
||||
--show-duration: var(--ha-dialog-show-duration, 200ms);
|
||||
@@ -226,8 +221,7 @@ export class HaWaDialog extends LitElement {
|
||||
--ha-dialog-border-radius,
|
||||
var(--ha-border-radius-3xl)
|
||||
);
|
||||
max-width: var(--ha-dialog-max-width, 100vw);
|
||||
max-width: var(--ha-dialog-max-width, 100svw);
|
||||
max-width: var(--ha-dialog-max-width, var(--safe-width));
|
||||
}
|
||||
|
||||
:host([width="small"]) wa-dialog {
|
||||
@@ -235,7 +229,7 @@ export class HaWaDialog extends LitElement {
|
||||
}
|
||||
|
||||
:host([width="large"]) wa-dialog {
|
||||
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
|
||||
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
|
||||
}
|
||||
|
||||
:host([width="full"]) wa-dialog {
|
||||
@@ -247,34 +241,57 @@ export class HaWaDialog extends LitElement {
|
||||
max-width: var(--width, var(--full-width));
|
||||
max-height: var(
|
||||
--ha-dialog-max-height,
|
||||
calc(100% - var(--ha-space-20))
|
||||
calc(var(--safe-height) - var(--ha-space-20))
|
||||
);
|
||||
min-height: var(--ha-dialog-min-height);
|
||||
position: var(--dialog-surface-position, relative);
|
||||
margin-top: var(--dialog-surface-margin-top, auto);
|
||||
/* Used to offset the dialog from the safe areas when space is limited */
|
||||
transform: translate(
|
||||
calc(
|
||||
var(--safe-area-offset-left, var(--ha-space-0)) - var(
|
||||
--safe-area-offset-right,
|
||||
var(--ha-space-0)
|
||||
)
|
||||
),
|
||||
calc(
|
||||
var(--safe-area-offset-top, var(--ha-space-0)) - var(
|
||||
--safe-area-offset-bottom,
|
||||
var(--ha-space-0)
|
||||
)
|
||||
)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
:host {
|
||||
:host([type="standard"]) {
|
||||
--ha-dialog-border-radius: var(--ha-space-0);
|
||||
}
|
||||
|
||||
wa-dialog {
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
}
|
||||
wa-dialog {
|
||||
/* Make the container fill the whole screen width and not the safe width */
|
||||
--full-width: var(--ha-dialog-width-full, 100vw);
|
||||
--width: var(--full-width);
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100svh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100svh);
|
||||
padding-top: var(--safe-area-inset-top, var(--ha-space-0));
|
||||
padding-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
|
||||
padding-left: var(--safe-area-inset-left, var(--ha-space-0));
|
||||
padding-right: var(--safe-area-inset-right, var(--ha-space-0));
|
||||
wa-dialog::part(dialog) {
|
||||
/* Make the dialog fill the whole screen height and not the safe height */
|
||||
min-height: var(--ha-dialog-min-height, 100vh);
|
||||
min-height: var(--ha-dialog-min-height, 100dvh);
|
||||
max-height: var(--ha-dialog-max-height, 100vh);
|
||||
max-height: var(--ha-dialog-max-height, 100dvh);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
/* Use safe area as padding instead of the container size */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
/* Reset the transform to center the dialog */
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,52 @@ export interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
|
||||
floor?: FloorRegistryEntry;
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface AreaFloorValue {
|
||||
id: string;
|
||||
type: "floor" | "area";
|
||||
}
|
||||
|
||||
export const getAreasNestedInFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[]
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors,
|
||||
true
|
||||
) as (FloorNestedComboBoxItem | UnassignedAreasFloorComboBoxItem)[];
|
||||
|
||||
export const getAreasAndFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
@@ -40,7 +81,43 @@ export const getAreasAndFloors = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[]
|
||||
): FloorComboBoxItem[] => {
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors
|
||||
) as FloorComboBoxItem[];
|
||||
|
||||
const getAreasAndFloorsItems = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[],
|
||||
nested = false
|
||||
): (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
@@ -181,7 +258,11 @@ export const getAreasAndFloors = (
|
||||
|
||||
const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
|
||||
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
const items: (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] = [];
|
||||
|
||||
hierarchy.floors.forEach((f) => {
|
||||
const floor = haFloors[f.id];
|
||||
@@ -196,7 +277,7 @@ export const getAreasAndFloors = (
|
||||
})
|
||||
.flat();
|
||||
|
||||
items.push({
|
||||
const floorItem: FloorComboBoxItem | FloorNestedComboBoxItem = {
|
||||
id: formatId({ id: floor.floor_id, type: "floor" }),
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
@@ -208,41 +289,53 @@ export const getAreasAndFloors = (
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName || area.area_id,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [
|
||||
area.area_id,
|
||||
...(areaName ? [areaName] : []),
|
||||
...area.aliases,
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
items.push(floorItem);
|
||||
|
||||
items.push(
|
||||
...hierarchy.areas.map((areaId) => {
|
||||
const area = haAreas[areaId];
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
const floorAreasItems = floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
primary: areaName || area.area_id,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
search_labels: [
|
||||
area.area_id,
|
||||
...(areaName ? [areaName] : []),
|
||||
...area.aliases,
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (nested && floor) {
|
||||
(floorItem as unknown as FloorNestedComboBoxItem).areas = floorAreasItems;
|
||||
} else {
|
||||
items.push(...floorAreasItems);
|
||||
}
|
||||
});
|
||||
|
||||
const unassignedAreaItems = hierarchy.areas.map((areaId) => {
|
||||
const area = haAreas[areaId];
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
});
|
||||
|
||||
if (nested && unassignedAreaItems.length) {
|
||||
items.push({
|
||||
areas: unassignedAreaItems,
|
||||
} as UnassignedAreasFloorComboBoxItem);
|
||||
} else {
|
||||
items.push(...unassignedAreaItems);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type { EntityRegistryEntry } from "./entity_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export { subscribeAreaRegistry } from "./ws-area_registry";
|
||||
@@ -18,7 +21,10 @@ export interface AreaRegistryEntry extends RegistryEntry {
|
||||
temperature_entity_id: string | null;
|
||||
}
|
||||
|
||||
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
export type AreaEntityLookup = Record<
|
||||
string,
|
||||
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
>;
|
||||
|
||||
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
|
||||
|
||||
@@ -69,7 +75,7 @@ export const reorderAreaRegistryEntries = (
|
||||
});
|
||||
|
||||
export const getAreaEntityLookup = (
|
||||
entities: EntityRegistryEntry[]
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
): AreaEntityLookup => {
|
||||
const areaEntityLookup: AreaEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
|
||||
@@ -114,12 +114,6 @@ export interface StateTrigger extends BaseTrigger {
|
||||
for?: string | number | ForDict;
|
||||
}
|
||||
|
||||
export interface MqttTrigger extends BaseTrigger {
|
||||
trigger: "mqtt";
|
||||
topic: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
export interface GeoLocationTrigger extends BaseTrigger {
|
||||
trigger: "geo_location";
|
||||
source: string;
|
||||
@@ -127,6 +121,12 @@ export interface GeoLocationTrigger extends BaseTrigger {
|
||||
event: "enter" | "leave";
|
||||
}
|
||||
|
||||
export interface MqttTrigger extends BaseTrigger {
|
||||
trigger: "mqtt";
|
||||
topic: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
export interface HassTrigger extends BaseTrigger {
|
||||
trigger: "homeassistant";
|
||||
event: "start" | "shutdown";
|
||||
|
||||
@@ -137,8 +137,12 @@ const getCalendarDate = (dateObj: any): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getCalendars = (hass: HomeAssistant): Calendar[] =>
|
||||
Object.keys(hass.states)
|
||||
export const getCalendars = (
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
): Calendar[] => {
|
||||
const computedStyles = getComputedStyle(element);
|
||||
return Object.keys(hass.states)
|
||||
.filter(
|
||||
(eid) =>
|
||||
computeDomain(eid) === "calendar" &&
|
||||
@@ -149,8 +153,9 @@ export const getCalendars = (hass: HomeAssistant): Calendar[] =>
|
||||
.map((eid, idx) => ({
|
||||
...hass.states[eid],
|
||||
name: computeStateName(hass.states[eid]),
|
||||
backgroundColor: getColorByIndex(idx),
|
||||
backgroundColor: getColorByIndex(idx, computedStyles),
|
||||
}));
|
||||
};
|
||||
|
||||
export const createCalendarEvent = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -17,11 +17,19 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.helpers.label",
|
||||
groups: {
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
|
||||
groups: {
|
||||
template: {},
|
||||
trigger: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -50,7 +50,11 @@ export type DeviceEntityDisplayLookup = Record<
|
||||
EntityRegistryDisplayEntry[]
|
||||
>;
|
||||
|
||||
export type DeviceEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
export type DeviceEntityLookup<
|
||||
T extends EntityRegistryEntry | EntityRegistryDisplayEntry =
|
||||
| EntityRegistryEntry
|
||||
| EntityRegistryDisplayEntry,
|
||||
> = Record<string, T[]>;
|
||||
|
||||
export interface DeviceRegistryEntryMutableParams {
|
||||
area_id?: string | null;
|
||||
@@ -107,7 +111,7 @@ export const sortDeviceRegistryByName = (
|
||||
);
|
||||
|
||||
export const getDeviceEntityLookup = (
|
||||
entities: EntityRegistryEntry[]
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
): DeviceEntityLookup => {
|
||||
const deviceEntityLookup: DeviceEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
|
||||
14
src/data/esphome.ts
Normal file
14
src/data/esphome.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface ESPHomeEncryptionKey {
|
||||
encryption_key: string;
|
||||
}
|
||||
|
||||
export const fetchESPHomeEncryptionKey = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
): Promise<ESPHomeEncryptionKey> =>
|
||||
hass.callWS({
|
||||
type: "esphome/get_encryption_key",
|
||||
entry_id,
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const integrationsWithPanel = {
|
||||
bluetooth: "config/bluetooth",
|
||||
@@ -25,6 +25,8 @@ export type IntegrationType =
|
||||
| "entity"
|
||||
| "system";
|
||||
|
||||
export type DomainManifestLookup = Record<string, IntegrationManifest>;
|
||||
|
||||
export interface IntegrationManifest {
|
||||
is_built_in: boolean;
|
||||
overwrites_built_in?: boolean;
|
||||
|
||||
@@ -101,7 +101,10 @@ export const deleteLabelRegistryEntry = (
|
||||
});
|
||||
|
||||
export const getLabels = (
|
||||
hass: HomeAssistant,
|
||||
hassStates: HomeAssistant["states"],
|
||||
hassAreas: HomeAssistant["areas"],
|
||||
hassDevices: HomeAssistant["devices"],
|
||||
hassEntities: HomeAssistant["entities"],
|
||||
labels?: LabelRegistryEntry[],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
@@ -115,8 +118,8 @@ export const getLabels = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
const entities = Object.values(hass.entities);
|
||||
const devices = Object.values(hassDevices);
|
||||
const entities = Object.values(hassEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
@@ -170,7 +173,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -181,8 +184,9 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
return (
|
||||
stateObj &&
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
@@ -200,7 +204,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -208,7 +212,7 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -245,8 +249,8 @@ export const getLabels = (
|
||||
|
||||
if (areaIds) {
|
||||
areaIds.forEach((areaId) => {
|
||||
const area = hass.areas[areaId];
|
||||
area.labels.forEach((label) => usedLabels.add(label));
|
||||
const area = hassAreas[areaId];
|
||||
area?.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { MediaSelectorValue } from "../../selector";
|
||||
import type { LovelaceBadgeConfig } from "./badge";
|
||||
import type { LovelaceCardConfig } from "./card";
|
||||
import type { LovelaceSectionRawConfig } from "./section";
|
||||
import type {
|
||||
LovelaceSectionConfig,
|
||||
LovelaceSectionRawConfig,
|
||||
} from "./section";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
|
||||
export interface ShowViewConfig {
|
||||
@@ -33,6 +36,12 @@ export interface LovelaceViewHeaderConfig {
|
||||
badges_wrap?: "wrap" | "scroll";
|
||||
}
|
||||
|
||||
export interface LovelaceViewSidebarConfig {
|
||||
sections?: LovelaceSectionConfig[];
|
||||
content_label?: string;
|
||||
sidebar_label?: string;
|
||||
}
|
||||
|
||||
export interface LovelaceBaseViewConfig {
|
||||
index?: number;
|
||||
title?: string;
|
||||
@@ -56,6 +65,8 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
|
||||
cards?: LovelaceCardConfig[];
|
||||
sections?: LovelaceSectionRawConfig[];
|
||||
header?: LovelaceViewHeaderConfig;
|
||||
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
|
||||
sidebar?: LovelaceViewSidebarConfig;
|
||||
}
|
||||
|
||||
export interface LovelaceStrategyViewConfig extends LovelaceBaseViewConfig {
|
||||
|
||||
@@ -219,9 +219,13 @@ const tryDescribeAction = <T extends ActionType>(
|
||||
|
||||
if (config.action) {
|
||||
const [domain, serviceName] = config.action.split(".", 2);
|
||||
const descriptionPlaceholders =
|
||||
hass.services[domain][serviceName].description_placeholders;
|
||||
const service =
|
||||
hass.localize(`component.${domain}.services.${serviceName}.name`) ||
|
||||
hass.services[domain][serviceName]?.name;
|
||||
hass.localize(
|
||||
`component.${domain}.services.${serviceName}.name`,
|
||||
descriptionPlaceholders
|
||||
) || hass.services[domain][serviceName]?.name;
|
||||
|
||||
if (config.metadata) {
|
||||
return hass.localize(
|
||||
|
||||
@@ -332,7 +332,6 @@ export interface NumberSelector {
|
||||
max?: number;
|
||||
step?: number | "any";
|
||||
mode?: "box" | "slider";
|
||||
entity?: { domains: readonly string[] };
|
||||
unit_of_measurement?: string;
|
||||
slider_ticks?: boolean;
|
||||
translation_key?: string;
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { FloorComboBoxItem } from "./area_floor";
|
||||
import type { AreaRegistryEntry } from "./area_registry";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type { DevicePickerItem, DeviceRegistryEntry } from "./device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity_registry";
|
||||
import type {
|
||||
EntityComboBoxItem,
|
||||
EntityRegistryDisplayEntry,
|
||||
} from "./entity_registry";
|
||||
|
||||
export const TARGET_SEPARATOR = "________";
|
||||
|
||||
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
|
||||
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
|
||||
|
||||
export interface SingleHassServiceTarget {
|
||||
entity_id?: string;
|
||||
device_id?: string;
|
||||
area_id?: string;
|
||||
floor_id?: string;
|
||||
label_id?: string;
|
||||
}
|
||||
|
||||
export interface ExtractFromTargetResult {
|
||||
missing_areas: string[];
|
||||
missing_devices: string[];
|
||||
@@ -35,6 +50,39 @@ export const extractFromTarget = async (
|
||||
target,
|
||||
});
|
||||
|
||||
export const getTriggersForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
expandGroup = true
|
||||
) =>
|
||||
callWS<string[]>({
|
||||
type: "get_triggers_for_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const getConditionsForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
expandGroup = true
|
||||
) =>
|
||||
callWS<string[]>({
|
||||
type: "get_conditions_for_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const getServicesForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
expandGroup = true
|
||||
) =>
|
||||
callWS<string[]>({
|
||||
type: "get_services_for_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const areaMeetsFilter = (
|
||||
area: AreaRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
@@ -162,3 +210,32 @@ export const entityRegMeetsFilter = (
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getTargetComboBoxItemType = (
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -28,18 +28,24 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.triggers.groups.helpers.label",
|
||||
groups: {
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label",
|
||||
groups: {
|
||||
event: {},
|
||||
geo_location: {},
|
||||
homeassistant: {},
|
||||
mqtt: {},
|
||||
conversation: {},
|
||||
tag: {},
|
||||
template: {},
|
||||
webhook: {},
|
||||
persistent_notification: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import { mdiAlertOutline, mdiClose } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog-header";
|
||||
import "../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../components/ha-md-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-textfield";
|
||||
import type { HaTextField } from "../../components/ha-textfield";
|
||||
import "../../components/ha-wa-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { DialogBoxParams } from "./show-dialog-box";
|
||||
|
||||
@@ -19,12 +20,12 @@ class DialogBox extends LitElement {
|
||||
|
||||
@state() private _params?: DialogBoxParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _closeState?: "canceled" | "confirmed";
|
||||
|
||||
@query("ha-textfield") private _textField?: HaTextField;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
private _closePromise?: Promise<void>;
|
||||
|
||||
private _closeResolve?: () => void;
|
||||
@@ -34,6 +35,7 @@ class DialogBox extends LitElement {
|
||||
await this._closePromise;
|
||||
}
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -60,16 +62,30 @@ class DialogBox extends LitElement {
|
||||
this.hass.localize("ui.dialogs.generic.default_confirmation_title"));
|
||||
|
||||
return html`
|
||||
<ha-md-dialog
|
||||
open
|
||||
.disableCancelAction=${confirmPrompt}
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
type=${confirmPrompt ? "alert" : "standard"}
|
||||
?prevent-scrim-close=${confirmPrompt}
|
||||
@closed=${this._dialogClosed}
|
||||
type="alert"
|
||||
aria-labelledby="dialog-box-title"
|
||||
aria-describedby="dialog-box-description"
|
||||
>
|
||||
<div slot="headline">
|
||||
<span .title=${dialogTitle} id="dialog-box-title">
|
||||
<ha-dialog-header slot="header">
|
||||
${!confirmPrompt
|
||||
? html`<slot name="headerNavigationIcon" slot="navigationIcon">
|
||||
<ha-icon-button
|
||||
data-dialog="close"
|
||||
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button
|
||||
></slot>`
|
||||
: nothing}
|
||||
<span
|
||||
class=${classMap({ title: true, alert: confirmPrompt })}
|
||||
slot="title"
|
||||
id="dialog-box-title"
|
||||
>
|
||||
${this._params.warning
|
||||
? html`<ha-svg-icon
|
||||
.path=${mdiAlertOutline}
|
||||
@@ -78,13 +94,13 @@ class DialogBox extends LitElement {
|
||||
: nothing}
|
||||
${dialogTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div slot="content" id="dialog-box-description">
|
||||
</ha-dialog-header>
|
||||
<div id="dialog-box-description">
|
||||
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
|
||||
${this._params.prompt
|
||||
? html`
|
||||
<ha-textfield
|
||||
dialogInitialFocus
|
||||
autofocus
|
||||
value=${ifDefined(this._params.defaultValue)}
|
||||
.placeholder=${this._params.placeholder}
|
||||
.label=${this._params.inputLabel
|
||||
@@ -99,10 +115,11 @@ class DialogBox extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<ha-dialog-footer slot="footer">
|
||||
${confirmPrompt
|
||||
? html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@click=${this._dismiss}
|
||||
?autofocus=${!this._params.prompt && this._params.destructive}
|
||||
appearance="plain"
|
||||
@@ -114,6 +131,7 @@ class DialogBox extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._confirm}
|
||||
?autofocus=${!this._params.prompt && !this._params.destructive}
|
||||
variant=${this._params.destructive ? "danger" : "brand"}
|
||||
@@ -122,8 +140,8 @@ class DialogBox extends LitElement {
|
||||
? this._params.confirmText
|
||||
: this.hass.localize("ui.common.ok")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -148,20 +166,20 @@ class DialogBox extends LitElement {
|
||||
}
|
||||
|
||||
private _closeDialog() {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
this._dialog?.close();
|
||||
this._open = false;
|
||||
this._closePromise = new Promise((resolve) => {
|
||||
this._closeResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
if (!this._closeState) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
this._cancel();
|
||||
}
|
||||
this._closeState = undefined;
|
||||
this._params = undefined;
|
||||
this._open = false;
|
||||
this._closeResolve?.();
|
||||
this._closeResolve = undefined;
|
||||
}
|
||||
@@ -187,6 +205,14 @@ class DialogBox extends LitElement {
|
||||
ha-textfield {
|
||||
width: 100%;
|
||||
}
|
||||
.title.alert {
|
||||
padding: 0 var(--ha-space-2);
|
||||
}
|
||||
@media all and (min-width: 450px) and (min-height: 500px) {
|
||||
.title.alert {
|
||||
padding: 0 var(--ha-space-1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -621,9 +621,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
} else if (this._sortDirection === "asc") {
|
||||
this._sortDirection = "desc";
|
||||
} else {
|
||||
this._sortDirection = null;
|
||||
this._sortDirection = "asc";
|
||||
}
|
||||
this._sortColumn = this._sortDirection === null ? undefined : columnId;
|
||||
this._sortColumn = columnId;
|
||||
|
||||
fireEvent(this, "sorting-changed", {
|
||||
column: columnId,
|
||||
|
||||
@@ -87,7 +87,7 @@ class PanelCalendar extends LitElement {
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._calendars = getCalendars(this.hass);
|
||||
this._calendars = getCalendars(this.hass, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ class PanelCalendar extends LitElement {
|
||||
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
|
||||
dialogClosedCallback: ({ flowFinished }) => {
|
||||
if (flowFinished) {
|
||||
this._calendars = getCalendars(this.hass);
|
||||
this._calendars = getCalendars(this.hass, this);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,6 +84,8 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _hierarchy?: AreasFloorHierarchy;
|
||||
|
||||
private _blockHierarchyUpdate = false;
|
||||
@@ -167,7 +169,9 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.isWide=${this.isWide}
|
||||
back-path="/config"
|
||||
.backPath=${this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config"}
|
||||
.tabs=${configSections.areas}
|
||||
.route=${this.route}
|
||||
has-fab
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
@@ -213,7 +214,7 @@ export default class HaAutomationAction extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _addAction = (action: string) => {
|
||||
private _addAction = (action: string, target?: HassServiceTarget) => {
|
||||
let actions: Action[];
|
||||
if (action === PASTE_VALUE) {
|
||||
actions = this.actions.concat(deepClone(this._clipboard!.action));
|
||||
@@ -223,6 +224,7 @@ export default class HaAutomationAction extends LitElement {
|
||||
actions = this.actions.concat({
|
||||
action: getValueFromDynamic(action),
|
||||
metadata: {},
|
||||
target,
|
||||
});
|
||||
} else {
|
||||
const elClass = customElements.get(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
mdiInformationOutline,
|
||||
mdiLabel,
|
||||
mdiPlus,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-domain-icon";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
|
||||
|
||||
type Target = [string, string | undefined, string | undefined];
|
||||
|
||||
@customElement("ha-automation-add-items")
|
||||
export class HaAutomationAddItems extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public items?: {
|
||||
title: string;
|
||||
items: AddAutomationElementListItem[];
|
||||
}[];
|
||||
|
||||
@property() public error?: string;
|
||||
|
||||
@property({ attribute: "select-label" }) public selectLabel!: string;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel!: string;
|
||||
|
||||
@property({ attribute: false }) public target?: Target;
|
||||
|
||||
@property({ attribute: false }) public getLabel!: (
|
||||
id: string
|
||||
) => { name: string; icon?: string } | undefined;
|
||||
|
||||
@property({ attribute: false }) public configEntryLookup: Record<
|
||||
string,
|
||||
ConfigEntry
|
||||
> = {};
|
||||
|
||||
@property({ type: Boolean, attribute: "tooltip-description" })
|
||||
public tooltipDescription = false;
|
||||
|
||||
@state() private _itemsScrolled = false;
|
||||
|
||||
@query(".items")
|
||||
private _itemsDiv!: HTMLDivElement;
|
||||
|
||||
protected render() {
|
||||
return html`<div
|
||||
class=${classMap({
|
||||
items: true,
|
||||
blank: this.error || !this.items || !this.items.length,
|
||||
error: this.error,
|
||||
scrolled: this._itemsScrolled,
|
||||
})}
|
||||
@scroll=${this._onItemsScroll}
|
||||
>
|
||||
${!this.items && !this.error
|
||||
? this.selectLabel
|
||||
: this.error
|
||||
? html`${this.error}
|
||||
<div>${this._renderTarget(this.target)}</div>`
|
||||
: this.items && !this.items.length
|
||||
? html`${this.emptyLabel}
|
||||
${this.target
|
||||
? html`<div>${this._renderTarget(this.target)}</div>`
|
||||
: nothing}`
|
||||
: repeat(
|
||||
this.items,
|
||||
(_, index) => `item-group-${index}`,
|
||||
(itemGroup) =>
|
||||
this._renderItemList(itemGroup.title, itemGroup.items)
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderItemList(title, items?: AddAutomationElementListItem[]) {
|
||||
if (!items || !items.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="items-title">${title}</div>
|
||||
<ha-md-list>
|
||||
${repeat(
|
||||
items,
|
||||
(item) => item.key,
|
||||
(item) => html`
|
||||
<ha-md-list-item
|
||||
interactive
|
||||
type="button"
|
||||
.value=${item.key}
|
||||
@click=${this._selected}
|
||||
>
|
||||
<div slot="headline" class=${this.target ? "item-headline" : ""}>
|
||||
${item.name}${this._renderTarget(this.target)}
|
||||
</div>
|
||||
|
||||
${!this.tooltipDescription && item.description
|
||||
? html`<div slot="supporting-text">${item.description}</div>`
|
||||
: nothing}
|
||||
${item.icon
|
||||
? html`<span slot="start">${item.icon}</span>`
|
||||
: item.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${this.tooltipDescription && item.description
|
||||
? html`<ha-svg-icon
|
||||
tabindex="0"
|
||||
id=${`description-tooltip-${item.key}`}
|
||||
slot="end"
|
||||
.path=${mdiInformationOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip
|
||||
.for=${`description-tooltip-${item.key}`}
|
||||
@wa-show=${stopPropagation}
|
||||
@wa-hide=${stopPropagation}
|
||||
@wa-after-hide=${stopPropagation}
|
||||
@wa-after-show=${stopPropagation}
|
||||
>${item.description}</ha-tooltip
|
||||
> `
|
||||
: nothing}
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
class="plus"
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-md-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTarget = memoizeOne((target?: Target) => {
|
||||
if (!target) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="selected-target">
|
||||
${this._getSelectedTargetIcon(target[0], target[1])}
|
||||
<div class="label">${target[2]}</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
private _getSelectedTargetIcon(
|
||||
targetType: string,
|
||||
targetId: string | undefined
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!targetId) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (targetType === "floor") {
|
||||
return html`<ha-floor-icon
|
||||
.floor=${this.hass.floors[targetId]}
|
||||
></ha-floor-icon>`;
|
||||
}
|
||||
|
||||
if (targetType === "area" && this.hass.areas[targetId]) {
|
||||
const area = this.hass.areas[targetId];
|
||||
if (area.icon) {
|
||||
return html`<ha-icon .icon=${area.icon}></ha-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
if (targetType === "device" && this.hass.devices[targetId]) {
|
||||
const device = this.hass.devices[targetId];
|
||||
const configEntry = device.primary_config_entry
|
||||
? this.configEntryLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
const domain = configEntry?.domain;
|
||||
|
||||
if (domain) {
|
||||
return html`<ha-domain-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetType === "entity" && this.hass.states[targetId]) {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
if (stateObj) {
|
||||
return html`<state-badge
|
||||
.stateObj=${stateObj}
|
||||
.hass=${this.hass}
|
||||
.stateColor=${false}
|
||||
></state-badge>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetType === "label") {
|
||||
const label = this.getLabel(targetId);
|
||||
if (label?.icon) {
|
||||
return html`<ha-icon .icon=${label.icon}></ha-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon .path=${mdiLabel}></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _selected(ev) {
|
||||
const item = ev.currentTarget;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: item.value,
|
||||
});
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _onItemsScroll(ev) {
|
||||
const top = ev.target.scrollTop ?? 0;
|
||||
this._itemsScrolled = top > 0;
|
||||
}
|
||||
|
||||
public override scrollTo(options?: ScrollToOptions): void;
|
||||
|
||||
public override scrollTo(x: number, y: number): void;
|
||||
|
||||
public override scrollTo(
|
||||
xOrOptions?: number | ScrollToOptions,
|
||||
y?: number
|
||||
): void {
|
||||
if (typeof xOrOptions === "number") {
|
||||
this._itemsDiv?.scrollTo(xOrOptions, y!);
|
||||
} else {
|
||||
this._itemsDiv?.scrollTo(xOrOptions);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.items.blank {
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
background-color: var(--ha-color-surface-default);
|
||||
align-items: center;
|
||||
color: var(--ha-color-text-secondary);
|
||||
padding: var(--ha-space-0);
|
||||
margin: var(--ha-space-0) var(--ha-space-4)
|
||||
max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items.error {
|
||||
background-color: var(--ha-color-fill-danger-quiet-resting);
|
||||
color: var(--ha-color-on-danger-normal);
|
||||
}
|
||||
.items ha-md-list {
|
||||
--md-list-item-two-line-container-height: var(--ha-space-12);
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
--md-list-item-trailing-space: var(--md-list-item-leading-space);
|
||||
--md-list-item-bottom-space: var(--ha-space-2);
|
||||
--md-list-item-top-space: var(--md-list-item-bottom-space);
|
||||
--md-list-item-supporting-text-font: var(--ha-font-family-body);
|
||||
--ha-md-list-item-gap: var(--ha-space-3);
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-0) var(--ha-space-4);
|
||||
}
|
||||
.items ha-md-list ha-md-list-item {
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
|
||||
.items ha-md-list {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
|
||||
}
|
||||
|
||||
.items .item-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: var(--ha-space-9);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.items-title {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding-top: var(--ha-space-2);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
padding-inline-start: var(--ha-space-8);
|
||||
padding-inline-end: var(--ha-space-3);
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
ha-bottom-sheet .items-title {
|
||||
padding-top: var(--ha-space-3);
|
||||
}
|
||||
.scrolled .items-title:first-of-type {
|
||||
box-shadow: var(--bar-box-shadow);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
|
||||
ha-icon-next {
|
||||
width: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-svg-icon.plus {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.selected-target {
|
||||
display: inline-flex;
|
||||
gap: var(--ha-space-1);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
|
||||
color: var(--ha-color-on-neutral-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
.selected-target .label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-target ha-icon,
|
||||
.selected-target ha-svg-icon,
|
||||
.selected-target state-badge,
|
||||
.selected-target ha-domain-icon {
|
||||
display: flex;
|
||||
padding: var(--ha-space-1) 0;
|
||||
}
|
||||
|
||||
.selected-target state-badge {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.selected-target state-badge,
|
||||
.selected-target ha-floor-icon {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
.selected-target ha-domain-icon {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-add-items": HaAutomationAddItems;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type {
|
||||
HassServiceTarget,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
CONDITION_BUILDING_BLOCKS,
|
||||
subscribeConditions,
|
||||
} from "../../../../data/condition";
|
||||
import { subscribeLabFeatures } from "../../../../data/labs";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
@@ -73,19 +78,52 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _conditionKeys = new WeakMap<Condition, string>();
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
// @ts-ignore
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeConditions(this.hass, (conditions) =>
|
||||
this._addConditions(conditions)
|
||||
),
|
||||
subscribeLabFeatures(this.hass!.connection, (features) => {
|
||||
this._newTriggersAndConditions =
|
||||
features.find(
|
||||
(feature) =>
|
||||
feature.domain === "automation" &&
|
||||
feature.preview_feature === "new_triggers_conditions"
|
||||
)?.enabled ?? false;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private _addConditions(conditions: ConditionDescriptions) {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...conditions,
|
||||
};
|
||||
private _subscribeDescriptions() {
|
||||
this._unsubscribe();
|
||||
this._conditionDescriptions = {};
|
||||
this._unsub = subscribeConditions(this.hass, (descriptions) => {
|
||||
this._conditionDescriptions = {
|
||||
...this._conditionDescriptions,
|
||||
...descriptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_newTriggersAndConditions")) {
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
@@ -261,7 +299,7 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _addCondition = (value) => {
|
||||
private _addCondition = (value: string, target?: HassServiceTarget) => {
|
||||
let conditions: Condition[];
|
||||
if (value === PASTE_VALUE) {
|
||||
conditions = this.conditions.concat(
|
||||
@@ -270,6 +308,7 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
|
||||
} else if (isDynamic(value)) {
|
||||
conditions = this.conditions.concat({
|
||||
condition: getValueFromDynamic(value),
|
||||
target,
|
||||
});
|
||||
} else {
|
||||
const condition = value as Condition["condition"];
|
||||
|
||||
@@ -69,6 +69,45 @@ export class HaPlatformCondition extends LitElement {
|
||||
} else {
|
||||
this._manifest = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
oldValue?.condition !== this.condition?.condition &&
|
||||
this.condition &&
|
||||
this.description?.fields
|
||||
) {
|
||||
let updatedDefaultValue = false;
|
||||
const updatedOptions = {};
|
||||
const loadDefaults = !("options" in this.condition);
|
||||
// Set mandatory bools without a default value to false
|
||||
Object.entries(this.description.fields).forEach(([key, field]) => {
|
||||
if (
|
||||
field.selector &&
|
||||
field.required &&
|
||||
field.default === undefined &&
|
||||
"boolean" in field.selector &&
|
||||
updatedOptions[key] === undefined
|
||||
) {
|
||||
updatedDefaultValue = true;
|
||||
updatedOptions[key] = false;
|
||||
} else if (
|
||||
loadDefaults &&
|
||||
field.selector &&
|
||||
field.default !== undefined &&
|
||||
updatedOptions[key] === undefined
|
||||
) {
|
||||
updatedDefaultValue = true;
|
||||
updatedOptions[key] = field.default;
|
||||
}
|
||||
});
|
||||
if (updatedDefaultValue) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options: updatedOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -354,6 +393,10 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 0px calc(-1 * var(--ha-space-4));
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -327,14 +329,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
const date = new Date(automation.last_triggered);
|
||||
const now = new Date();
|
||||
const dayDifference = differenceInDays(now, date);
|
||||
const formattedTime = formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const elementId = "last-triggered-" + slugify(automation.entity_id);
|
||||
return html`
|
||||
${dayDifference > 3
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: relativeTime(date, locale)}
|
||||
? formattedTime
|
||||
: html`
|
||||
<ha-tooltip for=${elementId}>${formattedTime}</ha-tooltip>
|
||||
<span id=${elementId}>${relativeTime(date, locale)}</span>
|
||||
`}
|
||||
`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
|
||||
export const PASTE_VALUE = "__paste__";
|
||||
|
||||
export interface AddAutomationElementDialogParams {
|
||||
type: "trigger" | "condition" | "action";
|
||||
add: (key: string) => void;
|
||||
add: (key: string, target?: HassServiceTarget) => void;
|
||||
clipboardItem: string | undefined;
|
||||
}
|
||||
const loadDialog = () => import("./add-automation-element-dialog");
|
||||
|
||||
@@ -93,8 +93,12 @@ export default class HaAutomationSidebarAction extends LitElement {
|
||||
".",
|
||||
2
|
||||
);
|
||||
|
||||
title = `${domainToName(this.hass.localize, domain)}: ${
|
||||
this.hass.localize(`component.${domain}.services.${service}.name`) ||
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name`,
|
||||
this.hass.services[domain][service].description_placeholders
|
||||
) ||
|
||||
this.hass.services[domain][service]?.name ||
|
||||
title
|
||||
}`;
|
||||
|
||||
@@ -72,7 +72,6 @@ import "./types/ha-automation-trigger-event";
|
||||
import "./types/ha-automation-trigger-geo_location";
|
||||
import "./types/ha-automation-trigger-homeassistant";
|
||||
import "./types/ha-automation-trigger-list";
|
||||
import "./types/ha-automation-trigger-mqtt";
|
||||
import "./types/ha-automation-trigger-numeric_state";
|
||||
import "./types/ha-automation-trigger-persistent_notification";
|
||||
import "./types/ha-automation-trigger-platform";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type {
|
||||
HassServiceTarget,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
type Trigger,
|
||||
type TriggerList,
|
||||
} from "../../../../data/automation";
|
||||
import { subscribeLabFeatures } from "../../../../data/labs";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
@@ -66,16 +71,54 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _triggerKeys = new WeakMap<Trigger, string>();
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
// @ts-ignore
|
||||
@state() private _newTriggersAndConditions = false;
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeTriggers(this.hass, (triggers) => this._addTriggers(triggers)),
|
||||
subscribeLabFeatures(this.hass!.connection, (features) => {
|
||||
this._newTriggersAndConditions =
|
||||
features.find(
|
||||
(feature) =>
|
||||
feature.domain === "automation" &&
|
||||
feature.preview_feature === "new_triggers_conditions"
|
||||
)?.enabled ?? false;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private _addTriggers(triggers: TriggerDescriptions) {
|
||||
this._triggerDescriptions = { ...this._triggerDescriptions, ...triggers };
|
||||
private _subscribeDescriptions() {
|
||||
this._unsubscribe();
|
||||
this._triggerDescriptions = {};
|
||||
this._unsub = subscribeTriggers(this.hass, (descriptions) => {
|
||||
this._triggerDescriptions = {
|
||||
...this._triggerDescriptions,
|
||||
...descriptions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_newTriggersAndConditions")) {
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
@@ -174,13 +217,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _addTrigger = (value: string) => {
|
||||
private _addTrigger = (value: string, target?: HassServiceTarget) => {
|
||||
let triggers: Trigger[];
|
||||
if (value === PASTE_VALUE) {
|
||||
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
|
||||
} else if (isDynamic(value)) {
|
||||
triggers = this.triggers.concat({
|
||||
trigger: getValueFromDynamic(value),
|
||||
target,
|
||||
});
|
||||
} else {
|
||||
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { MqttTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "topic", required: true, selector: { text: {} } },
|
||||
{ name: "payload", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-mqtt")
|
||||
export class HaMQTTTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public trigger!: MqttTrigger;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
public static get defaultConfig(): MqttTrigger {
|
||||
return { trigger: "mqtt", topic: "" };
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-form
|
||||
.schema=${SCHEMA}
|
||||
.data=${this.trigger}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = ev.detail.value;
|
||||
fireEvent(this, "value-changed", { value: newTrigger });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.mqtt.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-trigger-mqtt": HaMQTTTrigger;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,16 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
context: { filter_entity: "entity_id" },
|
||||
selector: {
|
||||
attribute: {
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"auto_update",
|
||||
"available_modes",
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"effect",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"event_type",
|
||||
"event_types",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"frontend_stream_type",
|
||||
"has_date",
|
||||
"has_time",
|
||||
"hs_color",
|
||||
"hvac_mode",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"id",
|
||||
"latest_version",
|
||||
"max_color_temp_kelvin",
|
||||
"max_mireds",
|
||||
"max_temp",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_type",
|
||||
"media_position_updated_at",
|
||||
"media_title",
|
||||
"min_color_temp_kelvin",
|
||||
"min_mireds",
|
||||
"min_temp",
|
||||
"mode",
|
||||
"next_dawn",
|
||||
"next_dusk",
|
||||
"next_midnight",
|
||||
"next_noon",
|
||||
"next_rising",
|
||||
"next_setting",
|
||||
"operation_list",
|
||||
"operation_mode",
|
||||
"options",
|
||||
"percentage_step",
|
||||
"precipitation_unit",
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"pressure_unit",
|
||||
"release_notes",
|
||||
"release_summary",
|
||||
"release_url",
|
||||
"restored",
|
||||
"rgb_color",
|
||||
"rgbw_color",
|
||||
"shuffle",
|
||||
"skipped_version",
|
||||
"sound_mode_list",
|
||||
"sound_mode",
|
||||
"source_list",
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"step",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_modes",
|
||||
"target_temp_step",
|
||||
"temperature_unit",
|
||||
"title",
|
||||
"token",
|
||||
"unit_of_measurement",
|
||||
"user_id",
|
||||
"uuid",
|
||||
"visibility_unit",
|
||||
"wind_speed_unit",
|
||||
"xy_color",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
entity: { domains: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
entity: { domains: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value_template",
|
||||
selector: { template: {} },
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
|
||||
@customElement("ha-automation-trigger-numeric_state")
|
||||
export class HaNumericStateTrigger extends LitElement {
|
||||
@@ -153,7 +20,224 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _inputAboveIsEntity?: boolean;
|
||||
|
||||
@state() private _inputBelowIsEntity?: boolean;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | string[],
|
||||
inputAboveIsEntity?: boolean,
|
||||
inputBelowIsEntity?: boolean
|
||||
) =>
|
||||
[
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
selector: {
|
||||
attribute: {
|
||||
entity_id: entityId ? entityId[0] : undefined,
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"auto_update",
|
||||
"available_modes",
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"effect",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"event_type",
|
||||
"event_types",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"frontend_stream_type",
|
||||
"has_date",
|
||||
"has_time",
|
||||
"hs_color",
|
||||
"hvac_mode",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"id",
|
||||
"latest_version",
|
||||
"max_color_temp_kelvin",
|
||||
"max_mireds",
|
||||
"max_temp",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_type",
|
||||
"media_position_updated_at",
|
||||
"media_title",
|
||||
"min_color_temp_kelvin",
|
||||
"min_mireds",
|
||||
"min_temp",
|
||||
"mode",
|
||||
"next_dawn",
|
||||
"next_dusk",
|
||||
"next_midnight",
|
||||
"next_noon",
|
||||
"next_rising",
|
||||
"next_setting",
|
||||
"operation_list",
|
||||
"operation_mode",
|
||||
"options",
|
||||
"percentage_step",
|
||||
"precipitation_unit",
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"pressure_unit",
|
||||
"release_notes",
|
||||
"release_summary",
|
||||
"release_url",
|
||||
"restored",
|
||||
"rgb_color",
|
||||
"rgbw_color",
|
||||
"shuffle",
|
||||
"skipped_version",
|
||||
"sound_mode_list",
|
||||
"sound_mode",
|
||||
"source_list",
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"step",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_modes",
|
||||
"target_temp_step",
|
||||
"temperature_unit",
|
||||
"title",
|
||||
"token",
|
||||
"unit_of_measurement",
|
||||
"user_id",
|
||||
"uuid",
|
||||
"visibility_unit",
|
||||
"wind_speed_unit",
|
||||
"xy_color",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lower_limit",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
[
|
||||
"value",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
|
||||
),
|
||||
],
|
||||
[
|
||||
"input",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
|
||||
),
|
||||
],
|
||||
],
|
||||
},
|
||||
...(inputAboveIsEntity
|
||||
? ([
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)),
|
||||
{
|
||||
name: "upper_limit",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
[
|
||||
"value",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
|
||||
),
|
||||
],
|
||||
[
|
||||
"input",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
|
||||
),
|
||||
],
|
||||
],
|
||||
},
|
||||
...(inputBelowIsEntity
|
||||
? ([
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)),
|
||||
{
|
||||
name: "value_template",
|
||||
selector: { template: {} },
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const
|
||||
);
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues) {
|
||||
this._inputAboveIsEntity =
|
||||
this._inputAboveIsEntity ??
|
||||
(typeof this.trigger.above === "string" &&
|
||||
((this.trigger.above as string).startsWith("input_number.") ||
|
||||
(this.trigger.above as string).startsWith("number.") ||
|
||||
(this.trigger.above as string).startsWith("sensor.")));
|
||||
this._inputBelowIsEntity =
|
||||
this._inputBelowIsEntity ??
|
||||
(typeof this.trigger.below === "string" &&
|
||||
((this.trigger.below as string).startsWith("input_number.") ||
|
||||
(this.trigger.below as string).startsWith("number.") ||
|
||||
(this.trigger.below as string).startsWith("sensor.")));
|
||||
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
@@ -174,20 +258,39 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
|
||||
...trigger,
|
||||
entity_id: ensureArray(trigger.entity_id),
|
||||
for: createDurationData(trigger.for),
|
||||
}));
|
||||
private _data = memoizeOne(
|
||||
(
|
||||
inputAboveIsEntity: boolean,
|
||||
inputBelowIsEntity: boolean,
|
||||
trigger: NumericStateTrigger
|
||||
) => ({
|
||||
lower_limit: inputAboveIsEntity ? "input" : "value",
|
||||
upper_limit: inputBelowIsEntity ? "input" : "value",
|
||||
...trigger,
|
||||
entity_id: ensureArray(trigger.entity_id),
|
||||
for: createDurationData(trigger.for),
|
||||
})
|
||||
);
|
||||
|
||||
public render() {
|
||||
const data = this._data(this.trigger);
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
this.trigger.entity_id,
|
||||
this._inputAboveIsEntity,
|
||||
this._inputBelowIsEntity
|
||||
);
|
||||
|
||||
const data = this._data(
|
||||
this._inputAboveIsEntity!,
|
||||
this._inputBelowIsEntity!,
|
||||
this.trigger
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${schema}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._valueChanged}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@@ -199,6 +302,12 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = { ...ev.detail.value };
|
||||
|
||||
this._inputAboveIsEntity = newTrigger.lower_limit === "input";
|
||||
this._inputBelowIsEntity = newTrigger.upper_limit === "input";
|
||||
|
||||
delete newTrigger.lower_limit;
|
||||
delete newTrigger.upper_limit;
|
||||
|
||||
if (newTrigger.value_template === "") {
|
||||
delete newTrigger.value_template;
|
||||
}
|
||||
@@ -207,7 +316,7 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
|
||||
@@ -25,6 +25,16 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
"trigger",
|
||||
"target",
|
||||
"alias",
|
||||
"id",
|
||||
"variables",
|
||||
"enabled",
|
||||
"options",
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -52,6 +62,31 @@ export class HaPlatformTrigger extends LitElement {
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newValue: PlatformTrigger | undefined;
|
||||
|
||||
for (const key in this.trigger) {
|
||||
// Migrate old options to `options`
|
||||
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
if (newValue === undefined) {
|
||||
newValue = {
|
||||
...this.trigger,
|
||||
options: { [key]: this.trigger[key] },
|
||||
};
|
||||
} else {
|
||||
newValue.options![key] = this.trigger[key];
|
||||
}
|
||||
delete newValue[key];
|
||||
}
|
||||
if (newValue !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
this.trigger = newValue;
|
||||
}
|
||||
|
||||
const oldValue = changedProperties.get("trigger") as
|
||||
| undefined
|
||||
| this["trigger"];
|
||||
@@ -69,6 +104,46 @@ export class HaPlatformTrigger extends LitElement {
|
||||
} else {
|
||||
this._manifest = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
oldValue?.trigger !== this.trigger?.trigger &&
|
||||
this.trigger &&
|
||||
this.description?.fields
|
||||
) {
|
||||
let updatedDefaultValue = false;
|
||||
const updatedOptions = {};
|
||||
const loadDefaults = !("options" in this.trigger);
|
||||
// Set mandatory bools without a default value to false
|
||||
Object.entries(this.description.fields).forEach(([key, field]) => {
|
||||
if (
|
||||
field.selector &&
|
||||
field.required &&
|
||||
field.default === undefined &&
|
||||
"boolean" in field.selector &&
|
||||
updatedOptions[key] === undefined
|
||||
) {
|
||||
updatedDefaultValue = true;
|
||||
updatedOptions[key] = false;
|
||||
} else if (
|
||||
loadDefaults &&
|
||||
field.selector &&
|
||||
field.default !== undefined &&
|
||||
updatedOptions[key] === undefined
|
||||
) {
|
||||
updatedDefaultValue = true;
|
||||
updatedOptions[key] = field.default;
|
||||
}
|
||||
});
|
||||
|
||||
if (updatedDefaultValue) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options: updatedOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -354,6 +429,10 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 0px calc(-1 * var(--ha-space-4));
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mdiKey } from "@mdi/js";
|
||||
import { getConfigEntries } from "../../../../../../data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
|
||||
import { fetchESPHomeEncryptionKey } from "../../../../../../data/esphome";
|
||||
import type { HomeAssistant } from "../../../../../../types";
|
||||
import { showESPHomeEncryptionKeyDialog } from "../../../../integrations/integration-panels/esphome/show-dialog-esphome-encryption-key";
|
||||
import type { DeviceAction } from "../../../ha-config-device-page";
|
||||
|
||||
export const getESPHomeDeviceActions = async (
|
||||
el: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
device: DeviceRegistryEntry
|
||||
): Promise<DeviceAction[]> => {
|
||||
const actions: DeviceAction[] = [];
|
||||
|
||||
const configEntries = await getConfigEntries(hass, {
|
||||
domain: "esphome",
|
||||
});
|
||||
|
||||
const configEntry = configEntries.find((entry) =>
|
||||
device.config_entries.includes(entry.entry_id)
|
||||
);
|
||||
|
||||
if (!configEntry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entryId = configEntry.entry_id;
|
||||
|
||||
try {
|
||||
const encryptionKey = await fetchESPHomeEncryptionKey(hass, entryId);
|
||||
|
||||
if (encryptionKey.encryption_key) {
|
||||
actions.push({
|
||||
label: hass.localize(
|
||||
"ui.panel.config.devices.esphome.show_encryption_key"
|
||||
),
|
||||
icon: mdiKey,
|
||||
action: () =>
|
||||
showESPHomeEncryptionKeyDialog(el, {
|
||||
entry_id: entryId,
|
||||
encryption_key: encryptionKey.encryption_key,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to fetch ESPHome encryption key:", err);
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
@@ -1162,6 +1162,17 @@ export class HaConfigDevicePage extends LitElement {
|
||||
);
|
||||
deviceActions.push(...actions);
|
||||
}
|
||||
if (domains.includes("esphome")) {
|
||||
const esphome = await import(
|
||||
"./device-detail/integration-elements/esphome/device-actions"
|
||||
);
|
||||
const actions = await esphome.getESPHomeDeviceActions(
|
||||
this,
|
||||
this.hass,
|
||||
device
|
||||
);
|
||||
deviceActions.push(...actions);
|
||||
}
|
||||
if (domains.includes("matter")) {
|
||||
const matter = await import(
|
||||
"./device-detail/integration-elements/matter/device-actions"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiCancel,
|
||||
mdiChevronRight,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiMenuDown,
|
||||
mdiPlus,
|
||||
mdiTextureBox,
|
||||
mdiCancel,
|
||||
mdiDelete,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
|
||||
@@ -68,8 +68,8 @@ import type {
|
||||
DeviceRegistryEntry,
|
||||
} from "../../../data/device_registry";
|
||||
import {
|
||||
updateDeviceRegistryEntry,
|
||||
removeConfigEntryFromDevice,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity_registry";
|
||||
import {
|
||||
@@ -86,8 +86,8 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
@@ -318,7 +318,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
|
||||
})
|
||||
);
|
||||
|
||||
const deviceEntityLookup: DeviceEntityLookup = {};
|
||||
const deviceEntityLookup: DeviceEntityLookup<EntityRegistryEntry> = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity.device_id) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mdiClose, mdiContentCopy } 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 { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import "../../../../../components/ha-dialog-header";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-wa-dialog";
|
||||
import { haStyleDialog } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
import type { ESPHomeEncryptionKeyDialogParams } from "./show-dialog-esphome-encryption-key";
|
||||
|
||||
@customElement("dialog-esphome-encryption-key")
|
||||
class DialogESPHomeEncryptionKey extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: ESPHomeEncryptionKeyDialogParams;
|
||||
|
||||
public async showDialog(
|
||||
params: ESPHomeEncryptionKeyDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.encryption_key_title"
|
||||
)}
|
||||
>
|
||||
<ha-dialog-header slot="heading">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
dialogAction="cancel"
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span slot="title">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.encryption_key_title"
|
||||
)}
|
||||
</span>
|
||||
</ha-dialog-header>
|
||||
|
||||
<div class="content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.encryption_key_description"
|
||||
)}
|
||||
</p>
|
||||
<div class="key-row">
|
||||
<div class="key-container">
|
||||
<code>${this._params.encryption_key}</code>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
@click=${this._copyToClipboard}
|
||||
.label=${this.hass.localize("ui.common.copy")}
|
||||
.path=${mdiContentCopy}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _copyToClipboard(): Promise<void> {
|
||||
if (!this._params?.encryption_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(this._params.encryption_key);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.key-row {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-container {
|
||||
flex: 1;
|
||||
border-radius: var(--ha-space-2);
|
||||
border: 1px solid var(--divider-color);
|
||||
background-color: var(
|
||||
--code-editor-background-color,
|
||||
var(--secondary-background-color)
|
||||
);
|
||||
padding: var(--ha-space-3);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-esphome-encryption-key": DialogESPHomeEncryptionKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
|
||||
export interface ESPHomeEncryptionKeyDialogParams {
|
||||
entry_id: string;
|
||||
encryption_key: string;
|
||||
}
|
||||
|
||||
export const loadESPHomeEncryptionKeyDialog = () =>
|
||||
import("./dialog-esphome-encryption-key");
|
||||
|
||||
export const showESPHomeEncryptionKeyDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: ESPHomeEncryptionKeyDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-esphome-encryption-key",
|
||||
dialogImport: loadESPHomeEncryptionKeyDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
subscribeEntityRegistry,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../../../../data/entity_registry";
|
||||
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
|
||||
import "./zwave-js-add-node-added-insecure";
|
||||
import "./zwave-js-add-node-code-input";
|
||||
import "./zwave-js-add-node-configure-device";
|
||||
@@ -69,7 +68,7 @@ import "./zwave-js-add-node-select-security-strategy";
|
||||
const INCLUSION_TIMEOUT_MINUTES = 5;
|
||||
|
||||
@customElement("dialog-zwave_js-add-node")
|
||||
class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
class DialogZWaveJSAddNode extends LitElement {
|
||||
// #region variables
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -103,6 +102,8 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _securityClasses: SecurityClass[] = [];
|
||||
|
||||
@state() private _entities: EntityRegistryEntry[] = [];
|
||||
|
||||
@state() private _codeInput = "";
|
||||
|
||||
@query("ha-dialog") private _dialog?: HaDialog;
|
||||
@@ -113,22 +114,14 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _onStop?: () => void;
|
||||
|
||||
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
|
||||
private _subscribedAddZwaveNode?: Promise<UnsubscribeFunc | undefined>;
|
||||
|
||||
private _newDeviceSubscription?: Promise<UnsubscribeFunc | undefined>;
|
||||
|
||||
@state() private _entities: EntityRegistryEntry[] = [];
|
||||
private _subscribedEntityRegistry?: UnsubscribeFunc;
|
||||
|
||||
// #endregion
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection, (entities) => {
|
||||
this._entities = entities;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._entryId) {
|
||||
return nothing;
|
||||
@@ -439,11 +432,6 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
></zwave-js-add-node-loading>`;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("beforeunload", this._onBeforeUnload);
|
||||
}
|
||||
|
||||
private _onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (this._step && this._shouldPreventClose(this._step)) {
|
||||
event.preventDefault();
|
||||
@@ -468,6 +456,14 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
public async showDialog(params: ZWaveJSAddNodeDialogParams): Promise<void> {
|
||||
window.addEventListener("beforeunload", this._onBeforeUnload);
|
||||
this._subscribedEntityRegistry = subscribeEntityRegistry(
|
||||
this.hass.connection,
|
||||
(entities) => {
|
||||
this._entities = entities;
|
||||
}
|
||||
);
|
||||
|
||||
if (this._step) {
|
||||
// already started
|
||||
return;
|
||||
@@ -562,7 +558,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
this._step = "select_method";
|
||||
break;
|
||||
case "search_devices":
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
if (
|
||||
this._supportsSmartStart &&
|
||||
this.hass.auth.external?.config.hasBarCodeScanner
|
||||
@@ -604,7 +600,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _searchDevicesShowSecurityOptions() {
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._step = "choose_security_strategy";
|
||||
}
|
||||
|
||||
@@ -626,7 +622,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
this._lowSecurity = false;
|
||||
|
||||
const s2Device = qrProvisioningInformation || dsk;
|
||||
this._subscribed = subscribeAddZwaveNode(
|
||||
this._subscribedAddZwaveNode = subscribeAddZwaveNode(
|
||||
this.hass,
|
||||
this._entryId!,
|
||||
(message) => {
|
||||
@@ -635,7 +631,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
this._step = s2Device ? "search_s2_device" : "search_devices";
|
||||
break;
|
||||
case "inclusion failed":
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._step = "failed";
|
||||
break;
|
||||
case "inclusion stopped":
|
||||
@@ -677,7 +673,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
this._lowSecurityReason = message.node.low_security_reason;
|
||||
break;
|
||||
case "interview completed":
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._step = "configure_device";
|
||||
break;
|
||||
}
|
||||
@@ -694,7 +690,7 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
this._addNodeTimeoutHandle = window.setTimeout(
|
||||
() => {
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.zwave_js.add_node.timeout_error",
|
||||
{ minutes: INCLUSION_TIMEOUT_MINUTES }
|
||||
@@ -1023,10 +1019,10 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private _unsubscribe(): void {
|
||||
if (this._subscribed) {
|
||||
this._subscribed.then((unsub) => unsub && unsub());
|
||||
this._subscribed = undefined;
|
||||
private _unsubscribeAddZwaveNode(): void {
|
||||
if (this._subscribedAddZwaveNode) {
|
||||
this._subscribedAddZwaveNode.then((unsub) => unsub && unsub());
|
||||
this._subscribedAddZwaveNode = undefined;
|
||||
|
||||
if (this._entryId) {
|
||||
stopZwaveInclusion(this.hass, this._entryId);
|
||||
@@ -1060,8 +1056,17 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
window.removeEventListener("beforeunload", this._onBeforeUnload);
|
||||
}
|
||||
|
||||
private _unsubscribeDialog() {
|
||||
if (this._subscribedEntityRegistry) {
|
||||
this._subscribedEntityRegistry();
|
||||
this._subscribedEntityRegistry = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._unsubscribe();
|
||||
window.removeEventListener("beforeunload", this._onBeforeUnload);
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._unsubscribeDialog();
|
||||
this._open = false;
|
||||
this._entryId = undefined;
|
||||
this._step = undefined;
|
||||
@@ -1100,7 +1105,8 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("beforeunload", this._onBeforeUnload);
|
||||
|
||||
this._unsubscribe();
|
||||
this._unsubscribeAddZwaveNode();
|
||||
this._unsubscribeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -94,7 +94,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
back-path="/config/system"
|
||||
.header=${this.hass.localize("ui.panel.config.labs.caption")}
|
||||
>
|
||||
${sortedFeatures.length
|
||||
|
||||
@@ -24,7 +24,7 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
|
||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
@@ -301,10 +301,21 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
const date = new Date(scene.state);
|
||||
const now = new Date();
|
||||
const dayDifference = differenceInDays(now, date);
|
||||
const formattedTime = formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const elementId = "last-activated-" + slugify(scene.entity_id);
|
||||
return html`
|
||||
${dayDifference > 3
|
||||
? formatShortDateTime(date, this.hass.locale, this.hass.config)
|
||||
: relativeTime(date, this.hass.locale)}
|
||||
? formattedTime
|
||||
: html`
|
||||
<ha-tooltip for=${elementId}>${formattedTime}</ha-tooltip>
|
||||
<span id=${elementId}
|
||||
>${relativeTime(date, this.hass.locale)}</span
|
||||
>
|
||||
`}
|
||||
`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,6 +33,8 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -302,19 +304,27 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
sortable: true,
|
||||
title: localize("ui.card.automation.last_triggered"),
|
||||
template: (script) => {
|
||||
if (!script.last_triggered) {
|
||||
return this.hass.localize("ui.components.relative_time.never");
|
||||
}
|
||||
const date = new Date(script.last_triggered);
|
||||
const now = new Date();
|
||||
const dayDifference = differenceInDays(now, date);
|
||||
const formattedTime = formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const elementId = "last-triggered-" + slugify(script.entity_id);
|
||||
return html`
|
||||
${script.last_triggered
|
||||
? dayDifference > 3
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: relativeTime(date, this.hass.locale)
|
||||
: this.hass.localize("ui.components.relative_time.never")}
|
||||
${dayDifference > 3
|
||||
? formattedTime
|
||||
: html`
|
||||
<ha-tooltip for=${elementId}>${formattedTime}</ha-tooltip>
|
||||
<span id=${elementId}
|
||||
>${relativeTime(date, this.hass.locale)}</span
|
||||
>
|
||||
`}
|
||||
`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { AssistPipeline } from "../../../../data/assist_pipeline";
|
||||
@@ -78,6 +79,18 @@ export class AssistPipelineDetailSTT extends LitElement {
|
||||
|
||||
private _supportedLanguagesChanged(ev) {
|
||||
this._supportedLanguages = ev.detail.value;
|
||||
|
||||
if (
|
||||
!this.data?.stt_language ||
|
||||
!this._supportedLanguages?.includes(this.data.stt_language)
|
||||
) {
|
||||
// wait for update of conversation_engine
|
||||
setTimeout(() => {
|
||||
const value = { ...this.data };
|
||||
value.stt_language = this._supportedLanguages?.[0] ?? null;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
@@ -122,6 +123,18 @@ export class AssistPipelineDetailTTS extends LitElement {
|
||||
|
||||
private _supportedLanguagesChanged(ev) {
|
||||
this._supportedLanguages = ev.detail.value;
|
||||
|
||||
if (
|
||||
!this.data?.tts_language ||
|
||||
!this._supportedLanguages?.includes(this.data?.tts_language)
|
||||
) {
|
||||
// wait for update of conversation_engine
|
||||
setTimeout(() => {
|
||||
const value = { ...this.data };
|
||||
value.tts_language = this._supportedLanguages?.[0] ?? null;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -135,6 +135,11 @@ class HaPanelDevAction extends LitElement {
|
||||
? computeObjectId(this._serviceData?.action)
|
||||
: undefined;
|
||||
|
||||
const descriptionPlaceholders =
|
||||
domain && serviceName
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<p>
|
||||
@@ -307,12 +312,14 @@ class HaPanelDevAction extends LitElement {
|
||||
<td><pre>${field.key}</pre></td>
|
||||
<td>
|
||||
${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.description`
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.description`,
|
||||
descriptionPlaceholders
|
||||
) || field.description}
|
||||
</td>
|
||||
<td>
|
||||
${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.example`
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.example`,
|
||||
descriptionPlaceholders
|
||||
) || field.example}
|
||||
</td>
|
||||
</tr>`
|
||||
@@ -643,7 +650,11 @@ class HaPanelDevAction extends LitElement {
|
||||
} catch (_err: any) {
|
||||
value =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.example`
|
||||
`component.${domain}.services.${serviceName}.fields.${field.key}.example`,
|
||||
domain && serviceName
|
||||
? this.hass.services[domain][serviceName]
|
||||
.description_placeholders
|
||||
: undefined
|
||||
) || field.example;
|
||||
}
|
||||
example[field.key] = value;
|
||||
|
||||
@@ -547,9 +547,9 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
} else if (this._sortDirection === "asc") {
|
||||
this._sortDirection = "desc";
|
||||
} else {
|
||||
this._sortDirection = null;
|
||||
this._sortDirection = "asc";
|
||||
}
|
||||
this._sortColumn = this._sortDirection === null ? undefined : columnId;
|
||||
this._sortColumn = columnId;
|
||||
}
|
||||
|
||||
private _handleGroupBy(ev) {
|
||||
|
||||
@@ -1,60 +1,77 @@
|
||||
import { mdiDownload, mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { mdiPencil, mdiDownload } from "@mdi/js";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../components/ha-menu-button";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-list-item";
|
||||
import "../../components/ha-menu-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import "../../components/ha-alert";
|
||||
import type { LovelaceConfig } from "../../data/lovelace/config/types";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../lovelace/components/hui-energy-period-selector";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type {
|
||||
BatterySourceTypeEnergyPreference,
|
||||
DeviceConsumptionEnergyPreference,
|
||||
EnergyPreferences,
|
||||
GasSourceTypeEnergyPreference,
|
||||
GridSourceTypeEnergyPreference,
|
||||
SolarSourceTypeEnergyPreference,
|
||||
BatterySourceTypeEnergyPreference,
|
||||
GasSourceTypeEnergyPreference,
|
||||
WaterSourceTypeEnergyPreference,
|
||||
DeviceConsumptionEnergyPreference,
|
||||
EnergyCollection,
|
||||
} from "../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
getEnergyDataCollection,
|
||||
getSummedData,
|
||||
} from "../../data/energy";
|
||||
import { fileDownload } from "../../util/file_download";
|
||||
import type { LovelaceConfig } from "../../data/lovelace/config/types";
|
||||
import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
|
||||
import type { StatisticValue } from "../../data/recorder";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { fileDownload } from "../../util/file_download";
|
||||
import "../lovelace/components/hui-energy-period-selector";
|
||||
import "../lovelace/hui-root";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
|
||||
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
|
||||
|
||||
const ENERGY_LOVELACE_CONFIG: LovelaceConfig = {
|
||||
views: [
|
||||
{
|
||||
strategy: {
|
||||
type: "energy-overview",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
},
|
||||
{
|
||||
strategy: {
|
||||
type: "energy-electricity",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
path: "electricity",
|
||||
},
|
||||
{
|
||||
type: "panel",
|
||||
path: "setup",
|
||||
cards: [{ type: "custom:energy-setup-wizard-card" }],
|
||||
},
|
||||
],
|
||||
const EMPTY_PREFERENCES: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
};
|
||||
|
||||
const OVERVIEW_VIEW = {
|
||||
path: "overview",
|
||||
strategy: {
|
||||
type: "energy-overview",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
} as LovelaceViewConfig;
|
||||
|
||||
const ELECTRICITY_VIEW = {
|
||||
path: "electricity",
|
||||
back_path: "/energy",
|
||||
strategy: {
|
||||
type: "energy-electricity",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
} as LovelaceViewConfig;
|
||||
|
||||
const WATER_VIEW = {
|
||||
back_path: "/energy",
|
||||
path: "water",
|
||||
strategy: {
|
||||
type: "energy-water",
|
||||
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
},
|
||||
} as LovelaceViewConfig;
|
||||
|
||||
const WIZARD_VIEW = {
|
||||
type: "panel",
|
||||
path: "setup",
|
||||
cards: [{ type: "custom:energy-setup-wizard-card" }],
|
||||
};
|
||||
|
||||
@customElement("ha-panel-energy")
|
||||
@@ -63,70 +80,96 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public panel?: PanelInfo;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@property({ attribute: false }) public route?: {
|
||||
path: string;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
private _energyCollection?: EnergyCollection;
|
||||
@state()
|
||||
private _prefs?: EnergyPreferences;
|
||||
|
||||
get _viewPath(): string | undefined {
|
||||
const viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
return viewPath ? decodeURI(viewPath) : undefined;
|
||||
}
|
||||
@state()
|
||||
private _error?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadPrefs();
|
||||
}
|
||||
|
||||
public async willUpdate(changedProps: PropertyValues) {
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadFragmentTranslation("lovelace");
|
||||
this._loadConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (oldHass?.locale !== this.hass.locale) {
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
} else if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._reloadView();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadPrefs() {
|
||||
if (this._viewPath === "setup") {
|
||||
await import("./cards/energy-setup-wizard-card");
|
||||
} else {
|
||||
this._energyCollection = getEnergyDataCollection(this.hass, {
|
||||
key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
});
|
||||
try {
|
||||
// Have to manually refresh here as we don't want to subscribe yet
|
||||
await this._energyCollection.refresh();
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
navigate("/energy/setup");
|
||||
}
|
||||
this._error = err.message;
|
||||
return;
|
||||
}
|
||||
const prefs = this._energyCollection.prefs!;
|
||||
if (
|
||||
prefs.device_consumption.length === 0 &&
|
||||
prefs.energy_sources.length === 0
|
||||
) {
|
||||
// No energy sources available, start from scratch
|
||||
navigate("/energy/setup");
|
||||
private _fetchEnergyPrefs = async (): Promise<
|
||||
EnergyPreferences | undefined
|
||||
> => {
|
||||
const collection = getEnergyDataCollection(this.hass, {
|
||||
key: DEFAULT_ENERGY_COLLECTION_KEY,
|
||||
});
|
||||
try {
|
||||
await collection.refresh();
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return collection.prefs;
|
||||
};
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
this._error = undefined;
|
||||
const prefs = await this._fetchEnergyPrefs();
|
||||
this._prefs = prefs || EMPTY_PREFERENCES;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load prefs:", err);
|
||||
this._prefs = EMPTY_PREFERENCES;
|
||||
this._error = (err as Error).message || "Unknown error";
|
||||
}
|
||||
await this._setLovelace();
|
||||
|
||||
// Navigate to first view if not there yet
|
||||
const firstPath = this._lovelace!.config?.views?.[0]?.path;
|
||||
const viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
if (viewPath !== firstPath) {
|
||||
navigate(`${this.route!.prefix}/${firstPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const config = await this._generateLovelaceConfig();
|
||||
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: config,
|
||||
editMode: false,
|
||||
urlPath: "energy",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private _back(ev) {
|
||||
@@ -135,31 +178,49 @@ class PanelEnergy extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._energyCollection?.prefs) {
|
||||
if (this._error) {
|
||||
return html`
|
||||
<div class="centered">
|
||||
<ha-alert alert-type="error">
|
||||
An error occurred loading energy preferences: ${this._error}
|
||||
</ha-alert>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._prefs) {
|
||||
// Still loading
|
||||
return html`<div class="centered">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
</div>`;
|
||||
return html`
|
||||
<div class="centered">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const { prefs } = this._energyCollection;
|
||||
const isSingleView = prefs.energy_sources.every((source) =>
|
||||
["grid", "solar", "battery"].includes(source.type)
|
||||
);
|
||||
let viewPath = this._viewPath;
|
||||
if (isSingleView) {
|
||||
// if only electricity sources, show electricity view directly
|
||||
viewPath = "electricity";
|
||||
|
||||
if (!this._lovelace) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
|
||||
const views = this._lovelace.config?.views || [];
|
||||
const viewIndex = Math.max(
|
||||
ENERGY_LOVELACE_CONFIG.views.findIndex((view) => view.path === viewPath),
|
||||
views.findIndex((view) => view.path === viewPath),
|
||||
0
|
||||
);
|
||||
const showBack =
|
||||
this._searchParms.has("historyBack") || (!isSingleView && viewIndex > 0);
|
||||
|
||||
const showBack = this._searchParms.has("historyBack") || viewIndex > 0;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="toolbar">
|
||||
<hui-root
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this.route}
|
||||
.panel=${this.panel}
|
||||
@reload-energy-panel=${this._reloadConfig}
|
||||
>
|
||||
<div class="toolbar" slot="toolbar">
|
||||
${showBack
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@@ -185,14 +246,17 @@ class PanelEnergy extends LitElement {
|
||||
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
|
||||
>
|
||||
${this.hass.user?.is_admin
|
||||
? html` <ha-list-item
|
||||
slot="overflow-menu"
|
||||
graphic="icon"
|
||||
@request-selected=${this._navigateConfig}
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPencil}> </ha-svg-icon>
|
||||
${this.hass!.localize("ui.panel.energy.configure")}
|
||||
</ha-list-item>`
|
||||
? html`
|
||||
<ha-list-item
|
||||
slot="overflow-menu"
|
||||
graphic="icon"
|
||||
@request-selected=${this._navigateConfig}
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPencil}>
|
||||
</ha-svg-icon>
|
||||
${this.hass!.localize("ui.panel.energy.configure")}
|
||||
</ha-list-item>
|
||||
`
|
||||
: nothing}
|
||||
<ha-list-item
|
||||
slot="overflow-menu"
|
||||
@@ -204,45 +268,40 @@ class PanelEnergy extends LitElement {
|
||||
</ha-list-item>
|
||||
</hui-energy-period-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hui-view-container
|
||||
.hass=${this.hass}
|
||||
@reload-energy-panel=${this._reloadView}
|
||||
>
|
||||
${this._error
|
||||
? html`<div class="centered">
|
||||
<ha-alert alert-type="error">
|
||||
An error occurred while fetching your energy preferences:
|
||||
${this._error}
|
||||
</ha-alert>
|
||||
</div>`
|
||||
: this._lovelace
|
||||
? html`<hui-view
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${viewIndex}
|
||||
></hui-view>`
|
||||
: nothing}
|
||||
</hui-view-container>
|
||||
</hui-root>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setLovelace() {
|
||||
this._lovelace = {
|
||||
config: ENERGY_LOVELACE_CONFIG,
|
||||
rawConfig: ENERGY_LOVELACE_CONFIG,
|
||||
editMode: false,
|
||||
urlPath: "energy",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
private async _generateLovelaceConfig(): Promise<LovelaceConfig> {
|
||||
if (
|
||||
!this._prefs ||
|
||||
(this._prefs.device_consumption.length === 0 &&
|
||||
this._prefs.energy_sources.length === 0)
|
||||
) {
|
||||
await import("./cards/energy-setup-wizard-card");
|
||||
return {
|
||||
views: [WIZARD_VIEW],
|
||||
};
|
||||
}
|
||||
|
||||
const isElectricityOnly = this._prefs.energy_sources.every((source) =>
|
||||
["grid", "solar", "battery"].includes(source.type)
|
||||
);
|
||||
if (isElectricityOnly) {
|
||||
return {
|
||||
views: [ELECTRICITY_VIEW],
|
||||
};
|
||||
}
|
||||
|
||||
const hasWater =
|
||||
this._prefs.energy_sources.some((source) => source.type === "water") ||
|
||||
this._prefs.device_consumption_water?.length > 0;
|
||||
|
||||
const views: LovelaceViewConfig[] = [OVERVIEW_VIEW, ELECTRICITY_VIEW];
|
||||
if (hasWater) {
|
||||
views.push(WATER_VIEW);
|
||||
}
|
||||
return { views };
|
||||
}
|
||||
|
||||
private _navigateConfig(ev) {
|
||||
@@ -252,7 +311,9 @@ class PanelEnergy extends LitElement {
|
||||
|
||||
private async _dumpCSV(ev) {
|
||||
ev.stopPropagation();
|
||||
const energyData = this._energyCollection!;
|
||||
const energyData = getEnergyDataCollection(this.hass, {
|
||||
key: "energy_dashboard",
|
||||
});
|
||||
|
||||
if (!energyData.prefs || !energyData.state.stats) {
|
||||
return;
|
||||
@@ -548,13 +609,8 @@ class PanelEnergy extends LitElement {
|
||||
fileDownload(url, "energy.csv");
|
||||
}
|
||||
|
||||
private _reloadView() {
|
||||
// Force strategy to be re-run by making a copy of the view
|
||||
const config = this._lovelace!.config;
|
||||
this._lovelace = {
|
||||
...this._lovelace!,
|
||||
config: { ...config, views: config.views.map((view) => ({ ...view })) },
|
||||
};
|
||||
private _reloadConfig() {
|
||||
this._loadConfig();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -580,45 +636,6 @@ class PanelEnergy extends LitElement {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
}
|
||||
.header {
|
||||
background-color: var(--app-header-background-color);
|
||||
color: var(--app-header-text-color, white);
|
||||
border-bottom: var(--app-header-border-bottom, none);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: calc(
|
||||
var(--mdc-top-app-bar-width, 100%) - var(
|
||||
--safe-area-inset-right,
|
||||
0px
|
||||
)
|
||||
);
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
z-index: 4;
|
||||
transition: box-shadow 200ms linear;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
:host([narrow]) .header {
|
||||
width: calc(
|
||||
var(--mdc-top-app-bar-width, 100%) - var(
|
||||
--safe-area-inset-left,
|
||||
0px
|
||||
) - var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
:host([scrolled]) .header {
|
||||
box-shadow: var(
|
||||
--mdc-top-app-bar-fixed-box-shadow,
|
||||
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
|
||||
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
|
||||
);
|
||||
}
|
||||
.toolbar {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
@@ -637,24 +654,6 @@ class PanelEnergy extends LitElement {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
flex-grow: 1;
|
||||
}
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-inline-end: var(--safe-area-inset-right);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
:host([narrow]) hui-view-container {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-inline-start: var(--safe-area-inset-left);
|
||||
}
|
||||
hui-view {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.centered {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -55,6 +55,9 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
|
||||
const hasPowerDevices = prefs.device_consumption.find(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
const showFloorsNAreas = !prefs.device_consumption.some(
|
||||
(d) => d.included_in_stat
|
||||
);
|
||||
|
||||
view.cards!.push({
|
||||
type: "energy-compare",
|
||||
@@ -67,6 +70,8 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
|
||||
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
|
||||
type: "power-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsNAreas,
|
||||
group_by_area: showFloorsNAreas,
|
||||
grid_options: {
|
||||
columns: 24,
|
||||
},
|
||||
@@ -156,9 +161,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
|
||||
|
||||
// Only include if we have at least 1 device in the config.
|
||||
if (prefs.device_consumption.length) {
|
||||
const showFloorsNAreas = !prefs.device_consumption.some(
|
||||
(d) => d.included_in_stat
|
||||
);
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
|
||||
type: "energy-sankey",
|
||||
|
||||
@@ -6,9 +6,16 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
|
||||
|
||||
const sourceHasCost = (source: Record<string, any>): boolean =>
|
||||
Boolean(
|
||||
source.stat_cost ||
|
||||
source.stat_compensation ||
|
||||
source.entity_energy_price ||
|
||||
source.number_energy_price
|
||||
);
|
||||
|
||||
@customElement("energy-overview-view-strategy")
|
||||
export class EnergyViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
@@ -44,10 +51,6 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
source.type === "grid" &&
|
||||
(source.flow_from?.length || source.flow_to?.length)
|
||||
) as GridSourceTypeEnergyPreference;
|
||||
const hasReturn = hasGrid && hasGrid.flow_to.length > 0;
|
||||
const hasSolar = prefs.energy_sources.some(
|
||||
(source) => source.type === "solar"
|
||||
);
|
||||
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
|
||||
const hasBattery = prefs.energy_sources.some(
|
||||
(source) => source.type === "battery"
|
||||
@@ -64,6 +67,13 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
const hasPowerDevices = prefs.device_consumption.find(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
const hasCost = prefs.energy_sources.some(
|
||||
(source) =>
|
||||
sourceHasCost(source) ||
|
||||
(source.type === "grid" &&
|
||||
(source.flow_from?.some(sourceHasCost) ||
|
||||
source.flow_to?.some(sourceHasCost)))
|
||||
);
|
||||
|
||||
const overviewSection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
@@ -71,10 +81,15 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
cards: [],
|
||||
};
|
||||
if (hasPowerSources && hasPowerDevices) {
|
||||
const showFloorsNAreas = !prefs.device_consumption.some(
|
||||
(d) => d.included_in_stat
|
||||
);
|
||||
overviewSection.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
|
||||
type: "power-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsNAreas,
|
||||
group_by_area: showFloorsNAreas,
|
||||
grid_options: {
|
||||
columns: 24,
|
||||
},
|
||||
@@ -88,10 +103,11 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
if (hasGrid || hasSolar || hasBattery || hasGas || hasWater) {
|
||||
if (hasCost) {
|
||||
overviewSection.cards!.push({
|
||||
type: "energy-sources-table",
|
||||
collection_key: collectionKey,
|
||||
show_only_totals: true,
|
||||
});
|
||||
}
|
||||
view.sections!.push(overviewSection);
|
||||
@@ -127,43 +143,10 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
modes: ["bar"],
|
||||
});
|
||||
} else if (hasGrid) {
|
||||
const gauges: LovelaceCardConfig[] = [];
|
||||
// Only include if we have a grid source & return.
|
||||
if (hasReturn) {
|
||||
gauges.push({
|
||||
type: "energy-grid-neutrality-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
gauges.push({
|
||||
type: "energy-carbon-consumed-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
|
||||
// Only include if we have a solar source.
|
||||
if (hasSolar) {
|
||||
if (hasReturn) {
|
||||
gauges.push({
|
||||
type: "energy-solar-consumed-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
gauges.push({
|
||||
type: "energy-self-sufficiency-gauge",
|
||||
view_layout: { position: "sidebar" },
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
electricitySection.cards!.push({
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: gauges,
|
||||
title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"),
|
||||
type: "energy-usage-graph",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +178,10 @@ export class EnergyViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.energy.overview.water"),
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/energy/water",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: hass.localize(
|
||||
|
||||
86
src/panels/energy/strategies/energy-water-view-strategy.ts
Normal file
86
src/panels/energy/strategies/energy-water-view-strategy.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getEnergyDataCollection } from "../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
|
||||
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
|
||||
|
||||
@customElement("energy-water-view-strategy")
|
||||
export class EnergyWaterViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: LovelaceStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view: LovelaceViewConfig = { cards: [] };
|
||||
|
||||
const collectionKey =
|
||||
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
|
||||
|
||||
const energyCollection = getEnergyDataCollection(hass, {
|
||||
key: collectionKey,
|
||||
});
|
||||
const prefs = energyCollection.prefs;
|
||||
|
||||
// No water sources available
|
||||
if (
|
||||
!prefs ||
|
||||
(!prefs.device_consumption_water?.length &&
|
||||
!prefs.energy_sources.some((source) => source.type === "water"))
|
||||
) {
|
||||
return view;
|
||||
}
|
||||
|
||||
view.type = "sidebar";
|
||||
|
||||
const hasWater = prefs.energy_sources.some(
|
||||
(source) => source.type === "water"
|
||||
);
|
||||
|
||||
view.cards!.push({
|
||||
type: "energy-compare",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
|
||||
if (hasWater) {
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
|
||||
type: "energy-water-graph",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasWater) {
|
||||
view.cards!.push({
|
||||
title: hass.localize(
|
||||
"ui.panel.energy.cards.energy_sources_table_title"
|
||||
),
|
||||
type: "energy-sources-table",
|
||||
collection_key: collectionKey,
|
||||
types: ["water"],
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have at least 1 water device in the config.
|
||||
if (prefs.device_consumption_water?.length) {
|
||||
const showFloorsNAreas = !prefs.device_consumption_water.some(
|
||||
(d) => d.included_in_stat
|
||||
);
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.water_sankey_title"),
|
||||
type: "water-sankey",
|
||||
collection_key: collectionKey,
|
||||
group_by_floor: showFloorsNAreas,
|
||||
group_by_area: showFloorsNAreas,
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"energy-water-view-strategy": EnergyWaterViewStrategy;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/ha-entities-picker";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-wa-dialog";
|
||||
@@ -78,6 +79,16 @@ export class DialogEditHome
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-entities-picker>
|
||||
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize("ui.panel.home.editor.areas_hint", {
|
||||
areas_page: html`<a
|
||||
href="/config/areas?historyBack=1"
|
||||
@click=${this.closeDialog}
|
||||
>${this.hass.localize("ui.panel.home.editor.areas_page")}</a
|
||||
>`,
|
||||
})}
|
||||
</ha-alert>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -140,6 +151,11 @@ export class DialogEditHome
|
||||
ha-entities-picker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -400,7 +400,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
? `${domainToName(this.hass.localize, item.context_domain)}:
|
||||
${
|
||||
this.hass.localize(
|
||||
`component.${item.context_domain}.services.${item.context_service}.name`
|
||||
`component.${item.context_domain}.services.${item.context_service}.name`,
|
||||
this.hass.services[item.context_domain][item.context_service]
|
||||
.description_placeholders
|
||||
) ||
|
||||
this.hass.services[item.context_domain]?.[item.context_service]?.name ||
|
||||
item.context_service
|
||||
|
||||
@@ -217,6 +217,9 @@ export class HuiEnergyDevicesGraphCard
|
||||
show: true,
|
||||
type: "value",
|
||||
name: "kWh",
|
||||
axisPointer: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
options.yAxis = {
|
||||
show: true,
|
||||
@@ -551,9 +554,12 @@ export class HuiEnergyDevicesGraphCard
|
||||
e.detail.seriesType === "pie" &&
|
||||
e.detail.event?.target?.type === "tspan" // label
|
||||
) {
|
||||
fireEvent(this, "hass-more-info", {
|
||||
entityId: (e.detail.data as any).id as string,
|
||||
});
|
||||
const id = (e.detail.data as any).id as string;
|
||||
if (id !== "untracked") {
|
||||
fireEvent(this, "hass-more-info", {
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,10 +166,24 @@ export class HuiEnergySourcesTableCard
|
||||
cost: number | null,
|
||||
compareCost: number | null,
|
||||
showCosts: boolean,
|
||||
compare: boolean
|
||||
compare: boolean,
|
||||
bulletColor?: { border: string; background: string },
|
||||
isFinalTotal?: boolean
|
||||
) {
|
||||
return html` <tr class="mdc-data-table__row total">
|
||||
<td class="mdc-data-table__cell"></td>
|
||||
return html` <tr
|
||||
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
|
||||
>
|
||||
<td class="mdc-data-table__cell cell-bullet">
|
||||
${bulletColor
|
||||
? html`<div
|
||||
class="bullet"
|
||||
style=${styleMap({
|
||||
borderColor: bulletColor.border,
|
||||
backgroundColor: bulletColor.background,
|
||||
})}
|
||||
></div>`
|
||||
: nothing}
|
||||
</td>
|
||||
<th class="mdc-data-table__cell" scope="row">${label}</th>
|
||||
${compare
|
||||
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
|
||||
@@ -339,6 +353,8 @@ export class HuiEnergySourcesTableCard
|
||||
};
|
||||
};
|
||||
|
||||
const showOnlyTotals = this._config.show_only_totals;
|
||||
|
||||
const _renderSimpleCategory = (type: "solar" | "gas" | "water") =>
|
||||
html` ${types[type]?.map((source, idx) => {
|
||||
const cost_stat =
|
||||
@@ -361,6 +377,10 @@ export class HuiEnergySourcesTableCard
|
||||
totalCostsCompare[type] += costCompare;
|
||||
}
|
||||
|
||||
if (showOnlyTotals) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return this._renderRow(
|
||||
computedStyles,
|
||||
type,
|
||||
@@ -386,7 +406,27 @@ export class HuiEnergySourcesTableCard
|
||||
hasCosts[type] ? totalCosts[type] : null,
|
||||
hasCosts[type] ? totalCostsCompare[type] : null,
|
||||
showCosts,
|
||||
compare
|
||||
compare,
|
||||
showOnlyTotals
|
||||
? {
|
||||
border: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
false,
|
||||
false,
|
||||
colorPropertyMap[type],
|
||||
0
|
||||
),
|
||||
background: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
true,
|
||||
false,
|
||||
colorPropertyMap[type],
|
||||
0
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
: ""}`;
|
||||
|
||||
@@ -474,6 +514,10 @@ export class HuiEnergySourcesTableCard
|
||||
totalBattery += energyFrom - energyTo;
|
||||
totalBatteryCompare += energyFromCompare - energyToCompare;
|
||||
|
||||
if (showOnlyTotals) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html` ${this._renderRow(
|
||||
computedStyles,
|
||||
"battery_out",
|
||||
@@ -511,7 +555,27 @@ export class HuiEnergySourcesTableCard
|
||||
null,
|
||||
null,
|
||||
showCosts,
|
||||
compare
|
||||
compare,
|
||||
showOnlyTotals
|
||||
? {
|
||||
border: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
false,
|
||||
false,
|
||||
colorPropertyMap.battery_out,
|
||||
0
|
||||
),
|
||||
background: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
true,
|
||||
false,
|
||||
colorPropertyMap.battery_out,
|
||||
0
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
: ""}
|
||||
${types.grid?.map(
|
||||
@@ -543,6 +607,11 @@ export class HuiEnergySourcesTableCard
|
||||
totalGridCost += cost;
|
||||
totalGridCostCompare += costCompare;
|
||||
}
|
||||
|
||||
if (showOnlyTotals) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return this._renderRow(
|
||||
computedStyles,
|
||||
"grid_consumption",
|
||||
@@ -583,6 +652,11 @@ export class HuiEnergySourcesTableCard
|
||||
totalGridCost -= cost;
|
||||
totalGridCostCompare -= costCompare;
|
||||
}
|
||||
|
||||
if (showOnlyTotals) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return this._renderRow(
|
||||
computedStyles,
|
||||
"grid_return",
|
||||
@@ -611,7 +685,27 @@ export class HuiEnergySourcesTableCard
|
||||
hasGridCost ? totalGridCost : null,
|
||||
hasGridCost ? totalGridCostCompare : null,
|
||||
showCosts,
|
||||
compare
|
||||
compare,
|
||||
showOnlyTotals
|
||||
? {
|
||||
border: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
false,
|
||||
false,
|
||||
colorPropertyMap.grid_consumption,
|
||||
0
|
||||
),
|
||||
background: getEnergyColor(
|
||||
computedStyles,
|
||||
this.hass.themes.darkMode,
|
||||
true,
|
||||
false,
|
||||
colorPropertyMap.grid_consumption,
|
||||
0
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
: ""}
|
||||
${_renderSimpleCategory("gas")} ${_renderSimpleCategory("water")}
|
||||
@@ -629,7 +723,9 @@ export class HuiEnergySourcesTableCard
|
||||
totalGridCostCompare +
|
||||
totalCostsCompare.water,
|
||||
showCosts,
|
||||
compare
|
||||
compare,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
: ""}
|
||||
</tbody>
|
||||
@@ -672,8 +768,8 @@ export class HuiEnergySourcesTableCard
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -23,6 +23,9 @@ const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum power threshold in kW to display a device node
|
||||
const MIN_POWER_THRESHOLD = 0.01;
|
||||
|
||||
interface PowerData {
|
||||
solar: number;
|
||||
from_grid: number;
|
||||
@@ -251,23 +254,75 @@ class HuiPowerSankeyCard
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
if (value >= MIN_POWER_THRESHOLD) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for power hierarchy
|
||||
// Walks up the chain to find an ancestor with stat_rate that will be rendered
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) {
|
||||
return undefined;
|
||||
}
|
||||
// If this parent has a stat_rate and will be rendered, use it
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
// Otherwise, continue up the chain
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
if (!device.stat_rate) {
|
||||
return;
|
||||
}
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
|
||||
if (value < 0.01) {
|
||||
if (value < MIN_POWER_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
parent: effectiveParent,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
|
||||
@@ -80,9 +80,10 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
throw new Error("Entities need to be an array");
|
||||
}
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
this._calendars = config!.entities.map((entity, idx) => ({
|
||||
entity_id: entity,
|
||||
backgroundColor: getColorByIndex(idx),
|
||||
backgroundColor: getColorByIndex(idx, computedStyles),
|
||||
}));
|
||||
|
||||
if (this._config?.entities !== config.entities) {
|
||||
|
||||
@@ -394,7 +394,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
color = getColorByIndex(this._colorIndex);
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = getColorByIndex(this._colorIndex, computedStyles);
|
||||
this._colorIndex++;
|
||||
this._colorDict[entityId] = color;
|
||||
return color;
|
||||
|
||||
@@ -194,6 +194,7 @@ export interface EnergySourcesTableCardConfig extends EnergyCardBaseConfig {
|
||||
type: "energy-sources-table";
|
||||
title?: string;
|
||||
types?: (keyof EnergySourceByType)[];
|
||||
show_only_totals?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySolarGaugeCardConfig extends EnergyCardBaseConfig {
|
||||
|
||||
@@ -98,17 +98,32 @@ class HuiWaterSankeyCard
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
|
||||
// Calculate total water consumption from all devices
|
||||
let totalWaterConsumption = 0;
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
// Calculate total water consumption from all sources or devices
|
||||
const totalDownstreamConsumption = prefs.device_consumption_water.reduce(
|
||||
(total, device) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
return total + value;
|
||||
},
|
||||
0
|
||||
);
|
||||
const totalSourceSupply = waterSources.reduce((total, source) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
source.stat_energy_from in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
this._data!.stats[source.stat_energy_from]
|
||||
) || 0
|
||||
: 0;
|
||||
totalWaterConsumption += value;
|
||||
});
|
||||
return total + value;
|
||||
}, 0);
|
||||
const totalWaterConsumption = Math.max(
|
||||
totalDownstreamConsumption,
|
||||
totalSourceSupply
|
||||
);
|
||||
|
||||
// Create home/consumption node
|
||||
const homeNode: Node = {
|
||||
|
||||
@@ -67,7 +67,10 @@ export const handleAction = async (
|
||||
await hass.loadBackendTranslation("title");
|
||||
const localize = await hass.loadBackendTranslation("services");
|
||||
serviceName = `${domainToName(localize, domain)}: ${
|
||||
localize(`component.${domain}.services.${service}.name`) ||
|
||||
localize(
|
||||
`component.${domain}.services.${service}.name`,
|
||||
hass.services[domain][service].description_placeholders
|
||||
) ||
|
||||
serviceDomains[domain][service].name ||
|
||||
service
|
||||
}`;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
removeSearchParam,
|
||||
} from "../../common/url/search-params";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { isMobileClient } from "../../util/is_mobile";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-button-menu";
|
||||
@@ -126,7 +127,7 @@ interface UndoStackItem {
|
||||
|
||||
@customElement("hui-root")
|
||||
class HUIRoot extends LitElement {
|
||||
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
|
||||
@property({ attribute: false }) public panel?: PanelInfo;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -294,7 +295,8 @@ class HUIRoot extends LitElement {
|
||||
overflowAction: this._handleShowQuickBar,
|
||||
visible: !this._editMode,
|
||||
overflow: this.narrow,
|
||||
suffix: this.hass.enableShortcuts ? "(E)" : undefined,
|
||||
suffix:
|
||||
this.hass.enableShortcuts && !isMobileClient ? "(E)" : undefined,
|
||||
},
|
||||
{
|
||||
icon: mdiCommentProcessingOutline,
|
||||
@@ -304,7 +306,8 @@ class HUIRoot extends LitElement {
|
||||
visible:
|
||||
!this._editMode && this._conversation(this.hass.config.components),
|
||||
overflow: this.narrow,
|
||||
suffix: this.hass.enableShortcuts ? "(A)" : undefined,
|
||||
suffix:
|
||||
this.hass.enableShortcuts && !isMobileClient ? "(A)" : undefined,
|
||||
},
|
||||
{
|
||||
icon: mdiRefresh,
|
||||
@@ -540,68 +543,72 @@ class HUIRoot extends LitElement {
|
||||
})}
|
||||
>
|
||||
<div class="header">
|
||||
<div class="toolbar">
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar">
|
||||
${this._editMode
|
||||
? html`
|
||||
<div class="main-title">
|
||||
${dashboardTitle ||
|
||||
this.hass!.localize("ui.panel.lovelace.editor.header")}
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_lovelace.edit_title"
|
||||
)}
|
||||
.path=${mdiPencil}
|
||||
class="edit-icon"
|
||||
@click=${this._editDashboard}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div class="action-items">${this._renderActionItems()}</div>
|
||||
`
|
||||
: html`
|
||||
${isSubview
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.hass=${this.hass}
|
||||
slot="navigationIcon"
|
||||
@click=${this._goBack}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-menu-button>
|
||||
`}
|
||||
${isSubview
|
||||
? html`
|
||||
<div class="main-title">${curViewConfig.title}</div>
|
||||
`
|
||||
: hasTabViews
|
||||
? tabs
|
||||
: html`
|
||||
<div class="main-title">
|
||||
${views[0]?.title ?? dashboardTitle}
|
||||
</div>
|
||||
`}
|
||||
<div class="action-items">${this._renderActionItems()}</div>
|
||||
`}
|
||||
</div>
|
||||
${this._editMode
|
||||
? html`
|
||||
<div class="main-title">
|
||||
${dashboardTitle ||
|
||||
this.hass!.localize("ui.panel.lovelace.editor.header")}
|
||||
<div class="tab-bar">
|
||||
${tabs}
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
slot="nav"
|
||||
id="add-view"
|
||||
@click=${this._addView}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_lovelace.edit_title"
|
||||
"ui.panel.lovelace.editor.edit_view.add"
|
||||
)}
|
||||
.path=${mdiPencil}
|
||||
class="edit-icon"
|
||||
@click=${this._editDashboard}
|
||||
.path=${mdiPlus}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div class="action-items">${this._renderActionItems()}</div>
|
||||
`
|
||||
: html`
|
||||
${isSubview
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.hass=${this.hass}
|
||||
slot="navigationIcon"
|
||||
@click=${this._goBack}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-menu-button>
|
||||
`}
|
||||
${isSubview
|
||||
? html`<div class="main-title">${curViewConfig.title}</div>`
|
||||
: hasTabViews
|
||||
? tabs
|
||||
: html`
|
||||
<div class="main-title">
|
||||
${views[0]?.title ?? dashboardTitle}
|
||||
</div>
|
||||
`}
|
||||
<div class="action-items">${this._renderActionItems()}</div>
|
||||
`}
|
||||
</div>
|
||||
${this._editMode
|
||||
? html`
|
||||
<div class="tab-bar">
|
||||
${tabs}
|
||||
<ha-icon-button
|
||||
slot="nav"
|
||||
id="add-view"
|
||||
@click=${this._addView}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.add"
|
||||
)}
|
||||
.path=${mdiPlus}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
</slot>
|
||||
</div>
|
||||
<hui-view-container
|
||||
class=${this._editMode ? "has-tab-bar" : ""}
|
||||
|
||||
@@ -42,6 +42,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
|
||||
import("../../energy/strategies/energy-overview-view-strategy"),
|
||||
"energy-electricity": () =>
|
||||
import("../../energy/strategies/energy-electricity-view-strategy"),
|
||||
"energy-water": () =>
|
||||
import("../../energy/strategies/energy-water-view-strategy"),
|
||||
map: () => import("./map/map-view-strategy"),
|
||||
iframe: () => import("./iframe/iframe-view-strategy"),
|
||||
area: () => import("./areas/area-view-strategy"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import {
|
||||
findEntities,
|
||||
@@ -23,8 +24,8 @@ import type {
|
||||
WeatherForecastCardConfig,
|
||||
} from "../../cards/types";
|
||||
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
|
||||
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
|
||||
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
|
||||
import type { Condition } from "../../common/validate-condition";
|
||||
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
@@ -70,8 +71,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
|
||||
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
|
||||
|
||||
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
|
||||
const maxColumns = 2;
|
||||
const maxColumns = 3;
|
||||
|
||||
const largeScreenCondition: Condition = {
|
||||
condition: "screen",
|
||||
media_query: "(min-width: 871px)",
|
||||
};
|
||||
|
||||
const floorsSections: LovelaceSectionConfig[] = [];
|
||||
for (const floorStructure of home.floors) {
|
||||
@@ -126,12 +131,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
const favoriteSection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
cards: [],
|
||||
};
|
||||
|
||||
const favoriteEntities = (config.favorite_entities || []).filter(
|
||||
(entityId) => hass.states[entityId] !== undefined
|
||||
);
|
||||
@@ -176,74 +175,70 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "light",
|
||||
vertical: true,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/light?historyBack=1",
|
||||
},
|
||||
grid_options: {
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
hasClimate &&
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "climate",
|
||||
vertical: true,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/climate?historyBack=1",
|
||||
},
|
||||
grid_options: {
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
hasSecurity &&
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "security",
|
||||
vertical: true,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/security?historyBack=1",
|
||||
},
|
||||
grid_options: {
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
hasMediaPlayers &&
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "media_players",
|
||||
vertical: true,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "media-players",
|
||||
},
|
||||
grid_options: {
|
||||
rows: 2,
|
||||
columns: 4,
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
].filter(Boolean) as LovelaceCardConfig[];
|
||||
|
||||
const summarySection: LovelaceSectionConfig = {
|
||||
const forYouSection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.lovelace.strategy.home.for_you"),
|
||||
heading_style: "title",
|
||||
visibility: [largeScreenCondition],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const widgetSection: LovelaceSectionConfig = {
|
||||
cards: [],
|
||||
};
|
||||
|
||||
if (summaryCards.length) {
|
||||
summarySection.cards!.push(
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"),
|
||||
},
|
||||
...summaryCards
|
||||
);
|
||||
widgetSection.cards!.push(...summaryCards);
|
||||
}
|
||||
|
||||
const weatherFilter = generateEntityFilter(hass, {
|
||||
@@ -251,28 +246,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
entity_category: "none",
|
||||
});
|
||||
|
||||
const widgetSection: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
cards: [],
|
||||
};
|
||||
const weatherEntity = Object.keys(hass.states)
|
||||
.filter(weatherFilter)
|
||||
.sort()[0];
|
||||
|
||||
if (weatherEntity) {
|
||||
widgetSection.cards!.push(
|
||||
{
|
||||
type: "heading",
|
||||
heading: "",
|
||||
heading_style: "subtitle",
|
||||
},
|
||||
{
|
||||
type: "weather-forecast",
|
||||
entity: weatherEntity,
|
||||
forecast_type: "daily",
|
||||
} as WeatherForecastCardConfig
|
||||
);
|
||||
widgetSection.cards!.push({
|
||||
type: "weather-forecast",
|
||||
entity: weatherEntity,
|
||||
forecast_type: "daily",
|
||||
} as WeatherForecastCardConfig);
|
||||
}
|
||||
|
||||
const energyPrefs = isComponentLoaded(hass, "energy")
|
||||
@@ -299,11 +282,19 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections = (
|
||||
[
|
||||
favoriteSection.cards && favoriteSection,
|
||||
{
|
||||
type: "grid",
|
||||
cards: [
|
||||
// Heading to add some spacing on large screens
|
||||
{
|
||||
type: "heading",
|
||||
heading_style: "subtitle",
|
||||
visibility: [largeScreenCondition],
|
||||
},
|
||||
],
|
||||
},
|
||||
commonControlsSection,
|
||||
summarySection.cards && summarySection,
|
||||
...floorsSections,
|
||||
widgetSection.cards && widgetSection,
|
||||
] satisfies (LovelaceSectionRawConfig | undefined)[]
|
||||
).filter(Boolean) as LovelaceSectionRawConfig[];
|
||||
|
||||
@@ -319,6 +310,11 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
content: `## ${hass.localize("ui.panel.lovelace.strategy.home.welcome_user", { user: "{{ user }}" })}`,
|
||||
} satisfies MarkdownCardConfig,
|
||||
},
|
||||
sidebar: {
|
||||
sections: [forYouSection, widgetSection],
|
||||
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
|
||||
sidebar_label: hass.localize("ui.panel.lovelace.strategy.home.for_you"),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import type { HuiSection } from "../sections/hui-section";
|
||||
import type { Lovelace } from "../types";
|
||||
import "./hui-view-header";
|
||||
import "./hui-view-sidebar";
|
||||
|
||||
export const DEFAULT_MAX_COLUMNS = 4;
|
||||
|
||||
@@ -46,6 +47,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
@property({ attribute: false }) public isStrategy = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public sections: HuiSection[] = [];
|
||||
|
||||
@property({ attribute: false }) public cards: HuiCard[] = [];
|
||||
@@ -58,6 +61,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
@state() _dragging = false;
|
||||
|
||||
@state() private _showSidebar = false;
|
||||
|
||||
private _contentScrollTop = 0;
|
||||
|
||||
private _sidebarScrollTop = 0;
|
||||
|
||||
private _columnsController = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
const totalWidth = entries[0]?.contentRect.width;
|
||||
@@ -114,6 +123,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._showSidebar = Boolean(window.history.state?.sidebar);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -135,16 +145,31 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
const sections = this.sections;
|
||||
const totalSectionCount =
|
||||
this._sectionColumnCount + (this.lovelace?.editMode ? 1 : 0);
|
||||
this._sectionColumnCount +
|
||||
(this.lovelace?.editMode ? 1 : 0) +
|
||||
(this._config?.sidebar ? 1 : 0);
|
||||
const editMode = this.lovelace.editMode;
|
||||
|
||||
const maxColumnCount = this._columnsController.value ?? 1;
|
||||
|
||||
const columnCount = Math.min(maxColumnCount, totalSectionCount);
|
||||
// On mobile with sidebar, use full width for whichever view is active
|
||||
const contentColumnCount =
|
||||
this._config?.sidebar && !this.narrow
|
||||
? Math.max(1, columnCount - 1)
|
||||
: columnCount;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="wrapper ${classMap({
|
||||
"top-margin": Boolean(this._config?.top_margin),
|
||||
"has-sidebar": Boolean(this._config?.sidebar),
|
||||
narrow: this.narrow,
|
||||
})}"
|
||||
style=${styleMap({
|
||||
"--column-count": columnCount,
|
||||
"--content-column-count": contentColumnCount,
|
||||
})}
|
||||
>
|
||||
<hui-view-header
|
||||
.hass=${this.hass}
|
||||
@@ -152,38 +177,54 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
.lovelace=${this.lovelace}
|
||||
.viewIndex=${this.index}
|
||||
.config=${this._config?.header}
|
||||
style=${styleMap({
|
||||
"--max-column-count": maxColumnCount,
|
||||
})}
|
||||
></hui-view-header>
|
||||
<ha-sortable
|
||||
.disabled=${!editMode}
|
||||
@item-moved=${this._sectionMoved}
|
||||
group="section"
|
||||
handle-selector=".handle"
|
||||
draggable-selector=".section"
|
||||
.rollback=${false}
|
||||
>
|
||||
<div
|
||||
class="container ${classMap({
|
||||
dense: Boolean(this._config?.dense_section_placement),
|
||||
})}"
|
||||
style=${styleMap({
|
||||
"--total-section-count": totalSectionCount,
|
||||
"--max-column-count": maxColumnCount,
|
||||
})}
|
||||
${this.narrow && this._config?.sidebar
|
||||
? html`
|
||||
<div class="mobile-tabs">
|
||||
<ha-control-select
|
||||
.value=${this._showSidebar ? "sidebar" : "content"}
|
||||
@value-changed=${this._viewChanged}
|
||||
.options=${[
|
||||
{
|
||||
value: "content",
|
||||
label: this._config.sidebar.content_label,
|
||||
},
|
||||
{
|
||||
value: "sidebar",
|
||||
label: this._config.sidebar.sidebar_label,
|
||||
},
|
||||
]}
|
||||
>
|
||||
</ha-control-select>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="container">
|
||||
<ha-sortable
|
||||
.disabled=${!editMode}
|
||||
@item-moved=${this._sectionMoved}
|
||||
group="section"
|
||||
handle-selector=".handle"
|
||||
draggable-selector=".section"
|
||||
.rollback=${false}
|
||||
>
|
||||
${repeat(
|
||||
sections,
|
||||
(section) => this._getSectionKey(section),
|
||||
(section, idx) => {
|
||||
const columnSpan = Math.min(
|
||||
section.config.column_span || 1,
|
||||
maxColumnCount
|
||||
);
|
||||
const rowSpan = section.config.row_span || 1;
|
||||
<div
|
||||
class="content ${classMap({
|
||||
dense: Boolean(this._config?.dense_section_placement),
|
||||
"mobile-hidden": this.narrow && this._showSidebar,
|
||||
})}"
|
||||
>
|
||||
${repeat(
|
||||
sections,
|
||||
(section) => this._getSectionKey(section),
|
||||
(section, idx) => {
|
||||
const columnSpan = Math.min(
|
||||
section.config.column_span || 1,
|
||||
contentColumnCount
|
||||
);
|
||||
const rowSpan = section.config.row_span || 1;
|
||||
|
||||
return html`
|
||||
return html`
|
||||
<div
|
||||
class="section"
|
||||
style=${styleMap({
|
||||
@@ -208,72 +249,89 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${editMode
|
||||
? html`
|
||||
<ha-sortable
|
||||
group="card"
|
||||
@item-added=${this._handleCardAdded}
|
||||
draggable-selector=".card"
|
||||
.rollback=${false}
|
||||
>
|
||||
<div class="create-section-container">
|
||||
<div class="drop-helper" aria-hidden="true">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.drop_card_create_section"
|
||||
}
|
||||
)}
|
||||
${editMode
|
||||
? html`
|
||||
<ha-sortable
|
||||
group="card"
|
||||
@item-added=${this._handleCardAdded}
|
||||
draggable-selector=".card"
|
||||
.rollback=${false}
|
||||
>
|
||||
<div class="create-section-container">
|
||||
<div class="drop-helper" aria-hidden="true">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.drop_card_create_section"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="create-section"
|
||||
@click=${this._createSection}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.create_section"
|
||||
)}
|
||||
</p>
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.create_section"
|
||||
)}
|
||||
>
|
||||
<ha-ripple></ha-ripple>
|
||||
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="create-section"
|
||||
@click=${this._createSection}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.create_section"
|
||||
)}
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.create_section"
|
||||
)}
|
||||
>
|
||||
<ha-ripple></ha-ripple>
|
||||
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
</button>
|
||||
</div>
|
||||
</ha-sortable>
|
||||
`
|
||||
: nothing}
|
||||
${editMode && this._config?.cards?.length
|
||||
? html`
|
||||
<div class="section imported-cards">
|
||||
<div class="imported-card-header">
|
||||
<p class="title">
|
||||
<ha-svg-icon .path=${mdiEyeOff}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.imported_cards_title"
|
||||
)}
|
||||
</p>
|
||||
<p class="subtitle">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.imported_cards_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<hui-section
|
||||
.lovelace=${this.lovelace}
|
||||
.hass=${this.hass}
|
||||
.config=${this._importedCardSectionConfig(
|
||||
this._config.cards
|
||||
</ha-sortable>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
${this._config?.sidebar
|
||||
? html`
|
||||
<hui-view-sidebar
|
||||
class=${classMap({
|
||||
"mobile-hidden": this.narrow && !this._showSidebar,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.badges=${this.badges}
|
||||
.lovelace=${this.lovelace}
|
||||
.viewIndex=${this.index}
|
||||
.config=${this._config.sidebar}
|
||||
></hui-view-sidebar>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="imported-cards-section">
|
||||
${editMode && this._config?.cards?.length
|
||||
? html`
|
||||
<div class="section imported-cards">
|
||||
<div class="imported-card-header">
|
||||
<p class="title">
|
||||
<ha-svg-icon .path=${mdiEyeOff}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.imported_cards_title"
|
||||
)}
|
||||
.viewIndex=${this.index}
|
||||
preview
|
||||
import-only
|
||||
></hui-section>
|
||||
</p>
|
||||
<p class="subtitle">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.section.imported_cards_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<hui-section
|
||||
.lovelace=${this.lovelace}
|
||||
.hass=${this.hass}
|
||||
.config=${this._importedCardSectionConfig(
|
||||
this._config.cards
|
||||
)}
|
||||
.viewIndex=${this.index}
|
||||
preview
|
||||
import-only
|
||||
></hui-section>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -352,6 +410,40 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
this.lovelace!.saveConfig(newConfig);
|
||||
}
|
||||
|
||||
private _viewChanged(ev: CustomEvent) {
|
||||
const newValue = ev.detail.value;
|
||||
const shouldShowSidebar = newValue === "sidebar";
|
||||
|
||||
if (shouldShowSidebar !== this._showSidebar) {
|
||||
this._toggleView();
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleView() {
|
||||
// Save current scroll position
|
||||
if (this._showSidebar) {
|
||||
this._sidebarScrollTop = window.scrollY;
|
||||
} else {
|
||||
this._contentScrollTop = window.scrollY;
|
||||
}
|
||||
|
||||
this._showSidebar = !this._showSidebar;
|
||||
|
||||
// Add sidebar state to history
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sidebar: this._showSidebar },
|
||||
""
|
||||
);
|
||||
|
||||
// Restore scroll position after view updates
|
||||
this.updateComplete.then(() => {
|
||||
const scrollY = this._showSidebar
|
||||
? this._sidebarScrollTop
|
||||
: this._contentScrollTop;
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
--row-height: var(--ha-view-sections-row-height, 56px);
|
||||
@@ -369,14 +461,19 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper.top-margin {
|
||||
.wrapper {
|
||||
display: block;
|
||||
margin-top: var(--top-margin);
|
||||
padding: var(--row-gap) var(--column-gap);
|
||||
box-sizing: content-box;
|
||||
margin: 0 auto;
|
||||
max-width: calc(
|
||||
var(--column-count) * var(--column-max-width) +
|
||||
(var(--column-count) - 1) * var(--column-gap)
|
||||
);
|
||||
}
|
||||
|
||||
.container > * {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
.wrapper.top-margin {
|
||||
margin-top: var(--top-margin);
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -390,22 +487,89 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
.container {
|
||||
--column-count: min(var(--max-column-count), var(--total-section-count));
|
||||
display: grid;
|
||||
grid-template-columns: [content-start] repeat(
|
||||
var(--content-column-count),
|
||||
1fr
|
||||
);
|
||||
gap: var(--row-gap) var(--column-gap);
|
||||
padding: var(--row-gap) 0;
|
||||
}
|
||||
|
||||
.wrapper.has-sidebar .container {
|
||||
grid-template-columns:
|
||||
[content-start] repeat(var(--content-column-count), 1fr)
|
||||
[sidebar-start] 1fr;
|
||||
}
|
||||
|
||||
/* On mobile with sidebar, content and sidebar both take full width */
|
||||
.wrapper.narrow.has-sidebar .container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
hui-view-sidebar {
|
||||
grid-column: sidebar-start / -1;
|
||||
}
|
||||
|
||||
.wrapper.narrow hui-view-sidebar {
|
||||
grid-column: 1 / -1;
|
||||
padding-bottom: calc(
|
||||
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
position: fixed;
|
||||
bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mobile-tabs ha-control-select {
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: 90%;
|
||||
--control-select-thickness: var(--ha-space-14);
|
||||
--control-select-border-radius: var(--ha-border-radius-pill);
|
||||
--control-select-background: var(--card-background-color);
|
||||
--control-select-background-opacity: 1;
|
||||
--control-select-color: var(--primary-color);
|
||||
--control-select-padding: 6px;
|
||||
box-shadow: rgba(0, 0, 0, 0.3) 0px 4px 10px 0px;
|
||||
}
|
||||
|
||||
ha-sortable {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-column: content-start / sidebar-start;
|
||||
grid-row: 1 / -1;
|
||||
display: grid;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(var(--column-count), 1fr);
|
||||
grid-template-columns: repeat(var(--content-column-count), 1fr);
|
||||
grid-auto-flow: row;
|
||||
gap: var(--row-gap) var(--column-gap);
|
||||
padding: var(--row-gap) var(--column-gap);
|
||||
box-sizing: content-box;
|
||||
margin: 0 auto;
|
||||
max-width: calc(
|
||||
var(--column-count) * var(--column-max-width) +
|
||||
(var(--column-count) - 1) * var(--column-gap)
|
||||
}
|
||||
|
||||
.wrapper.narrow .content {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wrapper.narrow.has-sidebar .content {
|
||||
padding-bottom: calc(
|
||||
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
.container.dense {
|
||||
|
||||
.content.dense {
|
||||
grid-auto-flow: row dense;
|
||||
}
|
||||
|
||||
@@ -483,13 +647,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
hui-view-header {
|
||||
display: block;
|
||||
padding: 0 var(--column-gap);
|
||||
padding-top: var(--row-gap);
|
||||
margin: auto;
|
||||
max-width: calc(
|
||||
var(--max-column-count) * var(--column-max-width) +
|
||||
(var(--max-column-count) - 1) * var(--column-gap)
|
||||
);
|
||||
}
|
||||
|
||||
.imported-cards {
|
||||
|
||||
57
src/panels/lovelace/views/hui-view-sidebar.ts
Normal file
57
src/panels/lovelace/views/hui-view-sidebar.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../sections/hui-section";
|
||||
import type { Lovelace } from "../types";
|
||||
|
||||
export const DEFAULT_VIEW_SIDEBAR_LAYOUT = "start";
|
||||
|
||||
@customElement("hui-view-sidebar")
|
||||
export class HuiViewSidebar extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public lovelace!: Lovelace;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceViewSidebarConfig;
|
||||
|
||||
@property({ attribute: false }) public viewIndex!: number;
|
||||
|
||||
render() {
|
||||
if (!this.lovelace) return nothing;
|
||||
|
||||
// Use preview mode instead of setting lovelace to avoid the sections to be
|
||||
// editable as it is not yet supported
|
||||
return html`
|
||||
<div class="container">
|
||||
${repeat(
|
||||
this.config?.sections || [],
|
||||
(section) => html`
|
||||
<hui-section
|
||||
.config=${section}
|
||||
.hass=${this.hass}
|
||||
.preview=${this.lovelace.editMode}
|
||||
.viewIndex=${this.viewIndex}
|
||||
></hui-section>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--row-gap, 8px);
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-view-sidebar": HuiViewSidebar;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { mdiViewDashboard } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../components/ha-divider";
|
||||
import "../../components/ha-icon";
|
||||
import "../../components/ha-list-item";
|
||||
import "../../components/ha-select";
|
||||
import "../../components/ha-settings-row";
|
||||
import "../../components/ha-svg-icon";
|
||||
import { saveFrontendUserData } from "../../data/frontend";
|
||||
import type { LovelaceDashboard } from "../../data/lovelace/dashboard";
|
||||
import { fetchDashboards } from "../../data/lovelace/dashboard";
|
||||
import { getPanelTitle } from "../../data/panel";
|
||||
import { getPanelIcon, getPanelTitle } from "../../data/panel";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards";
|
||||
|
||||
@@ -37,54 +40,57 @@ class HaPickDashboardRow extends LitElement {
|
||||
<span slot="description">
|
||||
${this.hass.localize("ui.panel.profile.dashboard.description")}
|
||||
</span>
|
||||
${this._dashboards
|
||||
? html`<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.profile.dashboard.dropdown_label"
|
||||
)}
|
||||
.disabled=${!this._dashboards?.length}
|
||||
.value=${value}
|
||||
@selected=${this._dashboardChanged}
|
||||
naturalMenuWidth
|
||||
>
|
||||
<ha-list-item .value=${USE_SYSTEM_VALUE}>
|
||||
${this.hass.localize("ui.panel.profile.dashboard.system")}
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.profile.dashboard.dropdown_label"
|
||||
)}
|
||||
.value=${value}
|
||||
@selected=${this._dashboardChanged}
|
||||
naturalMenuWidth
|
||||
>
|
||||
<ha-list-item .value=${USE_SYSTEM_VALUE}>
|
||||
${this.hass.localize("ui.panel.profile.dashboard.system")}
|
||||
</ha-list-item>
|
||||
<ha-divider></ha-divider>
|
||||
<ha-list-item value="lovelace" graphic="icon">
|
||||
<ha-svg-icon slot="graphic" .path=${mdiViewDashboard}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
|
||||
</ha-list-item>
|
||||
${PANEL_DASHBOARDS.map((panel) => {
|
||||
const panelInfo = this.hass.panels[panel] as PanelInfo | undefined;
|
||||
if (!panelInfo) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-list-item value=${panelInfo.url_path} graphic="icon">
|
||||
<ha-icon
|
||||
slot="graphic"
|
||||
.icon=${getPanelIcon(panelInfo)}
|
||||
></ha-icon>
|
||||
${getPanelTitle(this.hass, panelInfo)}
|
||||
</ha-list-item>
|
||||
<ha-divider></ha-divider>
|
||||
<ha-list-item value="lovelace">
|
||||
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
|
||||
</ha-list-item>
|
||||
${PANEL_DASHBOARDS.map((panel) => {
|
||||
const panelInfo = this.hass.panels[panel] as
|
||||
| PanelInfo
|
||||
| undefined;
|
||||
if (!panelInfo) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-list-item value=${panelInfo.url_path}>
|
||||
${getPanelTitle(this.hass, panelInfo)}
|
||||
</ha-list-item>
|
||||
`;
|
||||
})}
|
||||
<ha-divider></ha-divider>
|
||||
${this._dashboards.map((dashboard) => {
|
||||
if (!this.hass.user!.is_admin && dashboard.require_admin) {
|
||||
return "";
|
||||
}
|
||||
return html`
|
||||
<ha-list-item .value=${dashboard.url_path}>
|
||||
${dashboard.title}
|
||||
</ha-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-select>`
|
||||
: html`<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.profile.dashboard.dropdown_label"
|
||||
)}
|
||||
disabled
|
||||
></ha-select>`}
|
||||
`;
|
||||
})}
|
||||
${this._dashboards?.length
|
||||
? html`
|
||||
<ha-divider></ha-divider>
|
||||
${this._dashboards.map((dashboard) => {
|
||||
if (!this.hass.user!.is_admin && dashboard.require_admin) {
|
||||
return "";
|
||||
}
|
||||
return html`
|
||||
<ha-list-item .value=${dashboard.url_path} graphic="icon">
|
||||
<ha-icon
|
||||
slot="graphic"
|
||||
.icon=${dashboard.icon || "mdi:view-dashboard"}
|
||||
></ha-icon>
|
||||
${dashboard.title}
|
||||
</ha-list-item>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
: nothing}
|
||||
</ha-select>
|
||||
</ha-settings-row>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ interface PassThroughNode {
|
||||
id: string;
|
||||
value: number;
|
||||
depth: number;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
interface GraphLink extends GraphEdge {
|
||||
@@ -152,6 +154,8 @@ export function createPassThroughNode(
|
||||
id: `${sourceId}-${targetId}-${depth}`,
|
||||
value,
|
||||
depth,
|
||||
sourceId,
|
||||
targetId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +241,79 @@ export function groupNodesBySection(
|
||||
return nodesPerSection;
|
||||
}
|
||||
|
||||
export function sortNodesInSections(
|
||||
nodesPerSection: Record<number, Node[]>,
|
||||
depths: number[]
|
||||
): Record<number, Node[]> {
|
||||
const sortedSections: Record<number, Node[]> = {};
|
||||
|
||||
depths.forEach((depth, depthIndex) => {
|
||||
const sectionNodes = nodesPerSection[depth] || [];
|
||||
|
||||
// Sort nodes to minimize crossings
|
||||
const sortedNodes = [...sectionNodes].sort((a, b) => {
|
||||
const aIsPassthrough = isPassThroughNode(a);
|
||||
const bIsPassthrough = isPassThroughNode(b);
|
||||
|
||||
// Both are passthrough nodes - sort by source position
|
||||
if (aIsPassthrough && bIsPassthrough) {
|
||||
// Find positions of source nodes in previous section (use already sorted section)
|
||||
if (depthIndex > 0) {
|
||||
const prevDepth = depths[depthIndex - 1];
|
||||
const prevSection =
|
||||
sortedSections[prevDepth] || nodesPerSection[prevDepth] || [];
|
||||
|
||||
const aSourceIndex = prevSection.findIndex((n) => {
|
||||
const nodeId = isPassThroughNode(n) ? n.id : (n as GraphNode).id;
|
||||
return nodeId === a.sourceId;
|
||||
});
|
||||
const bSourceIndex = prevSection.findIndex((n) => {
|
||||
const nodeId = isPassThroughNode(n) ? n.id : (n as GraphNode).id;
|
||||
return nodeId === b.sourceId;
|
||||
});
|
||||
|
||||
if (
|
||||
aSourceIndex !== bSourceIndex &&
|
||||
aSourceIndex !== -1 &&
|
||||
bSourceIndex !== -1
|
||||
) {
|
||||
return aSourceIndex - bSourceIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to target node positions in next section (not sorted yet, use original)
|
||||
if (depthIndex < depths.length - 1) {
|
||||
const nextDepth = depths[depthIndex + 1];
|
||||
const nextSection = nodesPerSection[nextDepth] || [];
|
||||
|
||||
const aTargetIndex = nextSection.findIndex((n) => {
|
||||
const nodeId = isPassThroughNode(n) ? n.id : (n as GraphNode).id;
|
||||
return nodeId === a.targetId;
|
||||
});
|
||||
const bTargetIndex = nextSection.findIndex((n) => {
|
||||
const nodeId = isPassThroughNode(n) ? n.id : (n as GraphNode).id;
|
||||
return nodeId === b.targetId;
|
||||
});
|
||||
|
||||
if (
|
||||
aTargetIndex !== bTargetIndex &&
|
||||
aTargetIndex !== -1 &&
|
||||
bTargetIndex !== -1
|
||||
) {
|
||||
return aTargetIndex - bTargetIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
sortedSections[depth] = sortedNodes;
|
||||
});
|
||||
|
||||
return sortedSections;
|
||||
}
|
||||
|
||||
export function createSectionNodes(nodes: Node[]): SectionNode[] {
|
||||
return nodes.map(
|
||||
(node: Node): SectionNode => ({
|
||||
@@ -337,10 +414,11 @@ function processNodes(
|
||||
);
|
||||
|
||||
const nodesPerSection = groupNodesBySection(nodes, passThroughNodes);
|
||||
const sortedNodesPerSection = sortNodesInSections(nodesPerSection, depths);
|
||||
let globalValueToSizeRatio = 0;
|
||||
|
||||
const sections = depths.map((depth) => {
|
||||
const sectionNodes = createSectionNodes(nodesPerSection[depth] || []);
|
||||
const sectionNodes = createSectionNodes(sortedNodesPerSection[depth] || []);
|
||||
const availableSpace = sectionSize - (sectionNodes.length + 1) * nodeGap;
|
||||
const totalValue = sectionNodes.reduce(
|
||||
(acc: number, node: SectionNode) => acc + node.value,
|
||||
|
||||
@@ -92,6 +92,62 @@ export const colorStyles = css`
|
||||
--black-color: #000000;
|
||||
--white-color: #ffffff;
|
||||
|
||||
/* colors - used for graphs, calendars, maps, etc */
|
||||
--color-1: #4269d0;
|
||||
--color-2: #f4bd4a;
|
||||
--color-3: #ff725c;
|
||||
--color-4: #6cc5b0;
|
||||
--color-5: #a463f2;
|
||||
--color-6: #ff8ab7;
|
||||
--color-7: #9c6b4e;
|
||||
--color-8: #97bbf5;
|
||||
--color-9: #01ab63;
|
||||
--color-10: #094bad;
|
||||
--color-11: #c99000;
|
||||
--color-12: #d84f3e;
|
||||
--color-13: #49a28f;
|
||||
--color-14: #048732;
|
||||
--color-15: #d96895;
|
||||
--color-16: #8043ce;
|
||||
--color-17: #7599d1;
|
||||
--color-18: #7a4c31;
|
||||
--color-19: #6989f4;
|
||||
--color-20: #ffd444;
|
||||
--color-21: #ff957c;
|
||||
--color-22: #8fe9d3;
|
||||
--color-23: #62cc71;
|
||||
--color-24: #ffadda;
|
||||
--color-25: #c884ff;
|
||||
--color-26: #badeff;
|
||||
--color-27: #bf8b6d;
|
||||
--color-28: #927acc;
|
||||
--color-29: #97ee3f;
|
||||
--color-30: #bf3947;
|
||||
--color-31: #9f5b00;
|
||||
--color-32: #f48758;
|
||||
--color-33: #8caed6;
|
||||
--color-34: #f2b94f;
|
||||
--color-35: #eff26e;
|
||||
--color-36: #e43872;
|
||||
--color-37: #d9b100;
|
||||
--color-38: #9d7a00;
|
||||
--color-39: #698cff;
|
||||
--color-40: #00d27e;
|
||||
--color-41: #d06800;
|
||||
--color-42: #009f82;
|
||||
--color-43: #c49200;
|
||||
--color-44: #cbe8ff;
|
||||
--color-45: #fecddf;
|
||||
--color-46: #c27eb6;
|
||||
--color-47: #8cd2ce;
|
||||
--color-48: #c4b8d9;
|
||||
--color-49: #f883b0;
|
||||
--color-50: #a49100;
|
||||
--color-51: #f48800;
|
||||
--color-52: #27d0df;
|
||||
--color-53: #a04a9b;
|
||||
--color-54: #4269d0;
|
||||
|
||||
/* history colors */
|
||||
--history-unavailable-color: transparent;
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export const waColorStyles = css`
|
||||
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
|
||||
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
|
||||
|
||||
--wa-color-text-quiet: var(--ha-color-text-secondary);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
--wa-color-surface-default: var(--card-background-color);
|
||||
--wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff));
|
||||
@@ -62,5 +64,7 @@ export const waColorStyles = css`
|
||||
|
||||
--wa-focus-ring-color: var(--ha-color-neutral-60);
|
||||
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -27,14 +27,27 @@ export const mainStyles = css`
|
||||
--margin-title-ltr: 0 0 0 24px;
|
||||
--margin-title-rtl: 0 24px 0 0;
|
||||
|
||||
/* safe-area-insets */
|
||||
--safe-area-inset-top: var(--app-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||
--safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||
--safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||
--safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||
/* Safe area insets */
|
||||
--safe-area-inset-top: var(--app-safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||
--safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
--safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||
--safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||
|
||||
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px));
|
||||
/* Safe area inset x and y */
|
||||
--safe-area-inset-x: calc(var(--safe-area-inset-left, 0px) + var(--safe-area-inset-right, 0px));
|
||||
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px));
|
||||
|
||||
/* Offsets for centering elements within asymmetric safe areas */
|
||||
--safe-area-offset-left: calc(max(var(--safe-area-inset-left, 0px) - var(--safe-area-inset-right, 0px), 0px) / 2);
|
||||
--safe-area-offset-right: calc(max(var(--safe-area-inset-right, 0px) - var(--safe-area-inset-left, 0px), 0px) / 2);
|
||||
--safe-area-offset-top: calc(max(var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px), 0px) / 2);
|
||||
--safe-area-offset-bottom: calc(max(var(--safe-area-inset-bottom, 0px) - var(--safe-area-inset-top, 0px), 0px) / 2);
|
||||
|
||||
/* Safe width and height for use instead of 100vw and 100vh
|
||||
* when working with areas like dialogs which need to fill the entire safe area.
|
||||
*/
|
||||
--safe-width: calc(100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right));
|
||||
--safe-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -9,12 +9,16 @@ export const waMainStyles = css`
|
||||
--wa-focus-ring-offset: 2px;
|
||||
--wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color);
|
||||
|
||||
--wa-space-xs: var(--ha-space-2);
|
||||
--wa-space-m: var(--ha-space-4);
|
||||
--wa-space-l: var(--ha-space-6);
|
||||
--wa-space-xl: var(--ha-space-8);
|
||||
|
||||
--wa-form-control-padding-block: 0.75em;
|
||||
--wa-form-control-value-line-height: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-font-weight-action: var(--ha-font-weight-medium);
|
||||
--wa-transition-normal: 150ms;
|
||||
--wa-transition-fast: 75ms;
|
||||
--wa-transition-easing: ease;
|
||||
|
||||
@@ -28,6 +32,7 @@ export const waMainStyles = css`
|
||||
|
||||
--wa-line-height-condensed: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-font-size-m: var(--ha-font-size-m);
|
||||
--wa-shadow-s: var(--ha-box-shadow-s);
|
||||
--wa-shadow-m: var(--ha-box-shadow-m);
|
||||
--wa-shadow-l: var(--ha-box-shadow-l);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { HassElement } from "./hass-element";
|
||||
import { ShortcutManager } from "../common/keyboard/shortcuts";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { canOverrideAlphanumericInput } from "../common/dom/can-override-input";
|
||||
import { showShortcutsDialog } from "../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import type { Redirects } from "../panels/my/ha-panel-my";
|
||||
|
||||
@@ -87,6 +88,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
private _showVoiceCommandDialog(e: KeyboardEvent) {
|
||||
if (
|
||||
!this.hass?.enableShortcuts ||
|
||||
!canOverrideAlphanumericInput(e.composedPath()) ||
|
||||
!this._conversation(this.hass.config.components)
|
||||
) {
|
||||
return;
|
||||
@@ -104,7 +106,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
e: KeyboardEvent,
|
||||
mode: QuickBarMode = QuickBarMode.Entity
|
||||
) {
|
||||
if (!this._canShowQuickBar()) {
|
||||
if (!this._canShowQuickBar(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,7 +119,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
}
|
||||
|
||||
private _showShortcutDialog(e: KeyboardEvent) {
|
||||
if (!this._canShowQuickBar()) {
|
||||
if (!this._canShowQuickBar(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,7 +132,10 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
}
|
||||
|
||||
private async _createMyLink(e: KeyboardEvent) {
|
||||
if (!this.hass?.enableShortcuts) {
|
||||
if (
|
||||
!this.hass?.enableShortcuts ||
|
||||
!canOverrideAlphanumericInput(e.composedPath())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +194,11 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
});
|
||||
}
|
||||
|
||||
private _canShowQuickBar() {
|
||||
return this.hass?.user?.is_admin && this.hass.enableShortcuts;
|
||||
private _canShowQuickBar(e: KeyboardEvent) {
|
||||
return (
|
||||
this.hass?.user?.is_admin &&
|
||||
this.hass.enableShortcuts &&
|
||||
canOverrideAlphanumericInput(e.composedPath())
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,10 +529,6 @@
|
||||
"text": {
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password"
|
||||
},
|
||||
"number": {
|
||||
"value": "Value",
|
||||
"entity_value": "Entity value"
|
||||
}
|
||||
},
|
||||
"logbook": {
|
||||
@@ -2231,7 +2227,9 @@
|
||||
"title": "Edit home page",
|
||||
"description": "Configure your home page display preferences.",
|
||||
"favorite_entities_helper": "Display your favorite entities. Home Assistant will still suggest based on commonly used up to 8 slots.",
|
||||
"save_failed": "Failed to save home page configuration"
|
||||
"save_failed": "Failed to save home page configuration",
|
||||
"areas_hint": "You can rearrange your floors and areas in the order that best represents your house on the {areas_page}.",
|
||||
"areas_page": "areas page"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
@@ -4030,7 +4028,26 @@
|
||||
"item_pasted": "{item} pasted",
|
||||
"ctrl": "Ctrl",
|
||||
"del": "Del",
|
||||
"targets": "Targets",
|
||||
"select_target": "Select a target",
|
||||
"home": "Home",
|
||||
"unassigned": "Unassigned",
|
||||
"blocks": "Blocks",
|
||||
"show_more": "Show more",
|
||||
"unassigned_entities": "Unassigned entities",
|
||||
"unassigned_devices": "Unassigned devices",
|
||||
"empty_section_search": {
|
||||
"block": "No blocks found for {term}",
|
||||
"entity": "No entities found for {term}",
|
||||
"device": "No devices found for {term}",
|
||||
"area": "No areas or floors found for {term}",
|
||||
"label": "No labels found for {term}"
|
||||
},
|
||||
"load_target_items_failed": "Failed to load target items for",
|
||||
"other_areas": "Other areas",
|
||||
"services": "Services",
|
||||
"helpers": "Helpers",
|
||||
"entity_hidden": "[%key:ui::panel::config::devices::entities::hidden%]",
|
||||
"triggers": {
|
||||
"name": "Triggers",
|
||||
"header": "When",
|
||||
@@ -4038,7 +4055,10 @@
|
||||
"learn_more": "Learn more about triggers",
|
||||
"triggered": "Triggered",
|
||||
"add": "Add trigger",
|
||||
"empty_search": "No triggers found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No triggers and targets found for {term}",
|
||||
"item": "No triggers found for {term}"
|
||||
},
|
||||
"id": "Trigger ID",
|
||||
"optional": "Optional",
|
||||
"edit_id": "Edit ID",
|
||||
@@ -4059,6 +4079,7 @@
|
||||
"copied_to_clipboard": "Trigger copied to clipboard",
|
||||
"cut_to_clipboard": "Trigger cut to clipboard",
|
||||
"select": "Select a trigger",
|
||||
"no_items_for_target": "No triggers available for",
|
||||
"groups": {
|
||||
"device": {
|
||||
"label": "Device"
|
||||
@@ -4066,6 +4087,9 @@
|
||||
"entity": {
|
||||
"label": "Entity"
|
||||
},
|
||||
"helpers": {
|
||||
"label": "Helpers"
|
||||
},
|
||||
"time_location": {
|
||||
"label": "Time and location"
|
||||
},
|
||||
@@ -4154,7 +4178,11 @@
|
||||
"label": "Numeric state",
|
||||
"above": "Above",
|
||||
"below": "Below",
|
||||
"lower_limit": "Lower limit",
|
||||
"upper_limit": "Upper limit",
|
||||
"value_template": "Value template",
|
||||
"type_value": "Fixed number",
|
||||
"type_input": "Numeric value of another entity",
|
||||
"description": {
|
||||
"picker": "When the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
@@ -4296,7 +4324,10 @@
|
||||
"description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.",
|
||||
"learn_more": "Learn more about conditions",
|
||||
"add": "Add condition",
|
||||
"empty_search": "No conditions and blocks found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No conditions, blocks and targets found for {term}",
|
||||
"item": "No conditions found for {term}"
|
||||
},
|
||||
"add_building_block": "Add building block",
|
||||
"test": "Test",
|
||||
"testing_error": "Condition did not pass",
|
||||
@@ -4319,6 +4350,7 @@
|
||||
"copied_to_clipboard": "Condition copied to clipboard",
|
||||
"cut_to_clipboard": "Condition cut to clipboard",
|
||||
"select": "Select a condition",
|
||||
"no_items_for_target": "No conditions available for",
|
||||
"groups": {
|
||||
"device": {
|
||||
"label": "Device"
|
||||
@@ -4329,6 +4361,9 @@
|
||||
"time_location": {
|
||||
"label": "Time and location"
|
||||
},
|
||||
"helpers": {
|
||||
"label": "Helpers"
|
||||
},
|
||||
"other": {
|
||||
"label": "Other conditions"
|
||||
},
|
||||
@@ -4464,7 +4499,10 @@
|
||||
"description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.",
|
||||
"learn_more": "Learn more about actions",
|
||||
"add": "Add action",
|
||||
"empty_search": "No actions and blocks found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No actions, blocks and targets found for {term}",
|
||||
"item": "No actions found for {term}"
|
||||
},
|
||||
"add_building_block": "Add building block",
|
||||
"invalid_action": "Invalid action",
|
||||
"run": "Run action",
|
||||
@@ -4489,6 +4527,7 @@
|
||||
"copied_to_clipboard": "Action copied to clipboard",
|
||||
"cut_to_clipboard": "Action cut to clipboard",
|
||||
"select": "Select an action",
|
||||
"no_items_for_target": "No actions available for",
|
||||
"groups": {
|
||||
"device_id": {
|
||||
"label": "Device"
|
||||
@@ -5429,6 +5468,11 @@
|
||||
"partial_failure": "Some devices failed to delete successfully. Check system logs for more information."
|
||||
}
|
||||
}
|
||||
},
|
||||
"esphome": {
|
||||
"show_encryption_key": "Show encryption key",
|
||||
"encryption_key_title": "ESPHome Encryption Key",
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances."
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
@@ -7088,7 +7132,9 @@
|
||||
"unamed_device": "Unnamed device",
|
||||
"others": "Others",
|
||||
"scenes": "Scenes",
|
||||
"automations": "Automations"
|
||||
"automations": "Automations",
|
||||
"for_you": "For you",
|
||||
"home": "Home"
|
||||
},
|
||||
"common_controls": {
|
||||
"not_loaded": "Usage Prediction integration is not loaded.",
|
||||
@@ -7186,9 +7232,9 @@
|
||||
"solar_total": "Solar total",
|
||||
"water_total": "Water total",
|
||||
"source": "Source",
|
||||
"energy": "Energy",
|
||||
"energy": "Usage",
|
||||
"cost": "Cost",
|
||||
"previous_energy": "Previous energy",
|
||||
"previous_energy": "Previous usage",
|
||||
"previous_cost": "Previous cost",
|
||||
"battery_total": "Battery total",
|
||||
"total_costs": "Total costs"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
let askWrite;
|
||||
|
||||
const HASS_URL = `${location.protocol}//${location.host}`;
|
||||
|
||||
describe("token_storage.askWrite", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("__HASS_URL__", HASS_URL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -4,9 +4,12 @@ import { FallbackStorage } from "../../../test_helper/local-storage-fallback";
|
||||
|
||||
let saveTokens;
|
||||
|
||||
const HASS_URL = `${location.protocol}//${location.host}`;
|
||||
|
||||
describe("token_storage.saveTokens", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage = new FallbackStorage();
|
||||
vi.stubGlobal("__HASS_URL__", HASS_URL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { AuthData } from "home-assistant-js-websocket";
|
||||
import { FallbackStorage } from "../../../test_helper/local-storage-fallback";
|
||||
|
||||
const HASS_URL = `${location.protocol}//${location.host}`;
|
||||
|
||||
describe("token_storage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
@@ -11,6 +13,7 @@ describe("token_storage", () => {
|
||||
writeEnabled: undefined,
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("__HASS_URL__", HASS_URL);
|
||||
window.localStorage = new FallbackStorage();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,34 +2,63 @@ import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
getColorByIndex,
|
||||
getGraphColorByIndex,
|
||||
COLORS,
|
||||
COLORS_COUNT,
|
||||
} from "../../../src/common/color/colors";
|
||||
import { theme2hex } from "../../../src/common/color/convert-color";
|
||||
|
||||
describe("getColorByIndex", () => {
|
||||
test("return the correct color for a given index", () => {
|
||||
expect(getColorByIndex(0)).toBe(COLORS[0]);
|
||||
expect(getColorByIndex(10)).toBe(COLORS[10]);
|
||||
test("return the correct color from CSS variable", () => {
|
||||
const style = {
|
||||
getPropertyValue: (prop) => {
|
||||
if (prop === "--color-1") return "#4269d0";
|
||||
if (prop === "--color-11") return "#c99000";
|
||||
return "";
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
expect(getColorByIndex(0, style)).toBe(theme2hex("#4269d0"));
|
||||
expect(getColorByIndex(10, style)).toBe(theme2hex("#c99000"));
|
||||
});
|
||||
|
||||
test("wrap around if the index is greater than the length of COLORS", () => {
|
||||
expect(getColorByIndex(COLORS.length)).toBe(COLORS[0]);
|
||||
expect(getColorByIndex(COLORS.length + 4)).toBe(COLORS[4]);
|
||||
test("wrap around if the index is greater than the total count", () => {
|
||||
const style = {
|
||||
getPropertyValue: (prop) => {
|
||||
if (prop === "--color-1") return "#4269d0";
|
||||
if (prop === "--color-5") return "#a463f2";
|
||||
return "";
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
// Index 54 should wrap to color 1
|
||||
expect(getColorByIndex(COLORS_COUNT, style)).toBe(theme2hex("#4269d0"));
|
||||
// Index 58 should wrap to color 5
|
||||
expect(getColorByIndex(COLORS_COUNT + 4, style)).toBe(theme2hex("#a463f2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGraphColorByIndex", () => {
|
||||
test("return the correct theme color if it exists", () => {
|
||||
test("return color from --graph-color variable when it exists", () => {
|
||||
const style = {
|
||||
getPropertyValue: (prop) => (prop === "--graph-color-1" ? "#123456" : ""),
|
||||
} as CSSStyleDeclaration;
|
||||
expect(getGraphColorByIndex(0, style)).toBe(theme2hex("#123456"));
|
||||
});
|
||||
|
||||
test("return the default color if the theme color does not exist", () => {
|
||||
test("fallback to --color variable when --graph-color does not exist", () => {
|
||||
const style = {
|
||||
getPropertyValue: () => "",
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
expect(getGraphColorByIndex(0, style)).toBe(theme2hex(COLORS[0]));
|
||||
getPropertyValue: (prop) => (prop === "--color-5" ? "#abcdef" : ""),
|
||||
} as CSSStyleDeclaration;
|
||||
// Index 4 should try --graph-color-5, then fallback to --color-5
|
||||
expect(getGraphColorByIndex(4, style)).toBe(theme2hex("#abcdef"));
|
||||
});
|
||||
|
||||
test("prefer --graph-color over --color when both exist", () => {
|
||||
const style = {
|
||||
getPropertyValue: (prop) => {
|
||||
if (prop === "--graph-color-1") return "#111111";
|
||||
if (prop === "--color-1") return "#222222";
|
||||
return "";
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
// Should prefer --graph-color-1
|
||||
expect(getGraphColorByIndex(0, style)).toBe(theme2hex("#111111"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ describe("Sankey Layout Functions", () => {
|
||||
id: "test",
|
||||
value: 10,
|
||||
depth: 1,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
};
|
||||
expect(isPassThroughNode(passThroughNode)).toBe(true);
|
||||
});
|
||||
@@ -142,7 +144,14 @@ describe("Sankey Layout Functions", () => {
|
||||
];
|
||||
|
||||
const passThroughNodes = [
|
||||
{ id: "pt1", depth: 1, passThrough: true, value: 5 },
|
||||
{
|
||||
id: "pt1",
|
||||
depth: 1,
|
||||
passThrough: true,
|
||||
value: 5,
|
||||
sourceId: "node1",
|
||||
targetId: "node2",
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupNodesBySection(
|
||||
@@ -195,6 +204,8 @@ describe("Sankey Layout Functions", () => {
|
||||
passThrough: true,
|
||||
value: 5,
|
||||
depth: 1,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
};
|
||||
|
||||
const result = createSectionNodes([passThroughNode]);
|
||||
@@ -316,13 +327,15 @@ describe("Sankey Layout Functions", () => {
|
||||
|
||||
describe("createPassThroughNode", () => {
|
||||
it("should create a pass-through node", () => {
|
||||
const result = createPassThroughNode("source-target", "section1", 2, 15);
|
||||
const result = createPassThroughNode("source", "target", 2, 15);
|
||||
|
||||
expect(result).toEqual({
|
||||
passThrough: true,
|
||||
id: "source-target-section1-2",
|
||||
id: "source-target-2",
|
||||
value: 15,
|
||||
depth: 2,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user