Compare commits

..

63 Commits

Author SHA1 Message Date
Paul Bottein
da58dfe133 Improve new section button 2025-02-19 12:50:30 +01:00
Wendelin
2801d071ba Fix custom retention label (#24304) 2025-02-19 10:32:41 +01:00
Wendelin
71b65f208f Fix hassio backup restore url (#24313) 2025-02-19 10:32:15 +01:00
Paul Bottein
ab4efb7412 Fix cursor jump in light color pickers (#24312) 2025-02-19 10:30:24 +01:00
Logan Rosen
c7a46ec25b Improve ESLint config (#24290)
* Improve ESLint config
2025-02-18 17:30:36 +00:00
Jan-Philipp Benecke
83d4a408f6 Improve large maps with marker clustering (#24244)
* Improve large maps with marker clustering

* Pin leaflet.markercluster

* Remove custom icon

* Display whether marker are clustered or not
2025-02-18 15:45:39 +02:00
Bram Kragten
06932d1479 Prevent navigate when opening voice flow (#24300)
prevent navigate when opening voice flow
2025-02-18 14:27:59 +01:00
Wendelin
24211d5f25 Fix backup forever retention settings (#24299)
Fix forever retention settings
2025-02-18 14:05:04 +01:00
Bram Kragten
d387f19a31 Backup tweaks (#24165)
* Backup tweaks

* Show progress in fab

* Revert unused changes

---------

Co-authored-by: Wendelin <w@pe8.at>
2025-02-18 15:02:53 +02:00
Wendelin
347ee2a4c3 Improve-dev-container (#24296)
* Add gh cli to dev container

* Add develop and serve vscode task
2025-02-18 11:51:56 +02:00
karwosts
1363884773 Fix error handling/flickering in markdown card (#24280) 2025-02-18 08:16:10 +01:00
Adam Kapos
0256da511d Fix theme2hex with custom theme colors (#24282) 2025-02-18 08:04:46 +01:00
Petar Petrov
c52217c1ce Make part of the chart rendering async for large datasets (#24260) 2025-02-18 07:57:07 +01:00
karwosts
cdd17eed2e Fix untracked energy rendering at the base of the bar stack (#24288) 2025-02-18 06:36:53 +01:00
renovate[bot]
4546c6f624 Update babel monorepo to v7.26.9 (#24278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 06:35:47 +01:00
renovate[bot]
2c34760204 Update vaadinWebComponents monorepo to v24.6.5 (#24279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 06:35:25 +01:00
Paul Bottein
0b64861297 Add inline features position for tile card (#24199)
* Add side features position for tile card

* Add translations

* Rename to inline

* Simplify editor with 2 dropdowns

* Use 50% width

* Update src/translations/en.json

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-02-18 06:33:17 +01:00
renovate[bot]
94a5e737cc Update dependency @lit-labs/observers to v2.0.5 (#24286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 05:32:41 +00:00
renovate[bot]
05163588fc Update dependency @lit-labs/virtualizer to v2.1.0 (#24287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 06:21:23 +01:00
renovate[bot]
ee64536862 Update dependency @lit-labs/motion to v1.0.8 (#24289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 06:20:42 +01:00
renovate[bot]
695a6a506e Update octokit monorepo (#24292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 06:20:22 +01:00
Paul Bottein
3ee3cfa6cb Add cache for markdown card and markdown element (#24217)
* Add cache for markdown card and markdown element

* Rename to expiration

* Only use cache logic for markdown card

* Add tests

* Improve tests
2025-02-17 09:01:44 +01:00
renovate[bot]
00d0cb7afa Update dependency @octokit/auth-oauth-device to v7.1.3 (#24273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 08:39:01 +01:00
karwosts
3ae34403bd Fix duplicate id in energy-devices-detail-graph-card (#24261)
* Fix duplicate id in energy-devices-detail-graph-card

* address compare

* Update src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts

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

* prettier

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-02-16 13:06:01 +00:00
renovate[bot]
1434966170 Update dependency globals to v15.15.0 (#24262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 12:36:03 +01:00
renovate[bot]
8dd70f7017 Update dependency @codemirror/autocomplete to v6.18.6 (#24256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-16 12:35:58 +01:00
karwosts
84a0289e1b Use ha-md-button-menu in automation triggers/conditions (#24258) 2025-02-16 12:35:49 +01:00
renovate[bot]
a25e1d3f7f Update CodeMirror (#24255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-15 16:53:21 +01:00
libe.net
f53ac41eee Add timespans to history and energy (#23362)
* add Last 24h, 30d, 1y and overflow

* added Energy-Dashboard

* mobile style css

* added yesterday and min-height; changed overflow; new timespans to end;

* conflict resolve trial

* changed energy timespan order

* min for logbook

* seperated overflow calc for energy and logbook / history

* rename to header-position

* prettier format

* date-fns types

* added 1h, 12 h, 7d and removed 365d for history, added 7d to energy

* remove 7d for energy

* use calcdate and for energy whole hours / days / months

* fix calc
2025-02-15 09:52:32 +01:00
karwosts
b9acd40b0f Add zones to state picker for person/device_tracker (#24201)
* Add zones to state picker for person/device_tracker

* not for attributes
2025-02-14 22:44:05 +01:00
ildar170975
7524dc8709 Settings -> Helpers: make "Editable" columns sortable (#23976)
* "Editable" in "Helpers"

* prettier
2025-02-14 22:27:28 +01:00
renovate[bot]
cbedf62c39 Update dependency @codemirror/autocomplete to v6.18.5 (#24249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 22:16:15 +01:00
Wendelin
63a98155cd Add more unit tests for common/entity (#24182)
* Add new entity tests

* Improve canToggleDomain test
2025-02-14 21:55:23 +01:00
renovate[bot]
7369b7e0d5 Update dependency prettier to v3.5.1 (#24203)
* Update dependency prettier to v3.5.0

* Prettier 3.5.1

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-02-14 20:27:21 +00:00
dependabot[bot]
922abafabf Bump @octokit/plugin-paginate-rest from 11.4.0 to 11.4.2 (#24245)
Bumps [@octokit/plugin-paginate-rest](https://github.com/octokit/plugin-paginate-rest.js) from 11.4.0 to 11.4.2.
- [Release notes](https://github.com/octokit/plugin-paginate-rest.js/releases)
- [Commits](https://github.com/octokit/plugin-paginate-rest.js/compare/v11.4.0...v11.4.2)

---
updated-dependencies:
- dependency-name: "@octokit/plugin-paginate-rest"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-14 21:08:23 +01:00
Wendelin
f1bb4a5694 Add title attribute to data-table column header (#24231) 2025-02-14 21:07:48 +01:00
dependabot[bot]
e0b9cb8ccb Bump @octokit/request-error from 6.1.6 to 6.1.7 (#24243)
Bumps [@octokit/request-error](https://github.com/octokit/request-error.js) from 6.1.6 to 6.1.7.
- [Release notes](https://github.com/octokit/request-error.js/releases)
- [Commits](https://github.com/octokit/request-error.js/compare/v6.1.6...v6.1.7)

---
updated-dependencies:
- dependency-name: "@octokit/request-error"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-14 19:59:24 +00:00
dependabot[bot]
06f27650da Bump @octokit/request from 9.1.4 to 9.2.1 (#24242)
Bumps [@octokit/request](https://github.com/octokit/request.js) from 9.1.4 to 9.2.1.
- [Release notes](https://github.com/octokit/request.js/releases)
- [Commits](https://github.com/octokit/request.js/compare/v9.1.4...v9.2.1)

---
updated-dependencies:
- dependency-name: "@octokit/request"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-14 20:38:18 +01:00
renovate[bot]
a772eaffd7 Update dependency eslint to v9.20.1 (#24241)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 19:23:05 +00:00
dependabot[bot]
c39be4a9b8 Bump @octokit/endpoint from 10.1.1 to 10.1.3 (#24239)
Bumps [@octokit/endpoint](https://github.com/octokit/endpoint.js) from 10.1.1 to 10.1.3.
- [Release notes](https://github.com/octokit/endpoint.js/releases)
- [Commits](https://github.com/octokit/endpoint.js/compare/v10.1.1...v10.1.3)

---
updated-dependencies:
- dependency-name: "@octokit/endpoint"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-14 20:12:57 +01:00
Petar Petrov
0abccb88d6 Fix inclusion dialog in ZwaveJS panel (#24234) 2025-02-14 13:32:09 +01:00
renovate[bot]
5dc5879773 Update rspack monorepo to v1.2.3 (#24235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 13:10:38 +01:00
karwosts
41df7a3f4a Keyboard accessibility for automation-action-row (convert M2->M3) (#24121) 2025-02-14 13:02:16 +01:00
Petar Petrov
920ec035c5 Fix endTime of statistics-chart (#24233) 2025-02-14 12:53:48 +01:00
Petar Petrov
043e8d6e2e Optimize chart performance (#24215)
* Stop listening to chart scroll events to improve performance

* only set visualmap when needed

* Reduce statistics detail for long periods

* reduce calls to `setOption`

* tweak zoom modifier code

* always replace series

* revert statistics detail change
2025-02-14 12:46:55 +01:00
ildar170975
d8e36894a0 Fix for "Increase generic entity row touch target (4): iOS troubles (#24224)
restoring pre-2025.2 height
2025-02-14 09:45:56 +01:00
ildar170975
65b6a3c6a3 developer-tools-statistics: fix height of ha-data-table to avoid a double scrollbar (#24226)
height fix
2025-02-14 09:30:46 +01:00
ildar170975
b16f82cedb Settings->Entities: set width for "Status" (#23975)
* min-width for "Status"

* max-width for "Status"
2025-02-14 09:17:58 +01:00
Paul Bottein
02deeb4ce7 Increase target zone for tile card icon click (#24219) 2025-02-14 08:52:13 +02:00
renovate[bot]
0c6651c2c2 Update typescript-eslint monorepo to v8.24.0 (#24230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 08:47:20 +02:00
Paulus Schoutsen
abbf56db1d Fix config flow URLs linking to device (#24223) 2025-02-13 13:47:03 +00:00
Norbert Rittel
bc0cc8b387 Fix sentence-casing of running_parallel state for scripts (#24218)
Fix sentence-casing of running_parallel state of scripts
2025-02-13 12:54:52 +01:00
Abílio Costa
b66f41db7d Improve last backup status string (#24206) 2025-02-13 07:50:59 +01:00
renovate[bot]
05fbe204c5 Update dependency ua-parser-js to v2.0.2 (#24205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 07:47:40 +01:00
renovate[bot]
ee199fbbc0 Update dependency marked to v15.0.7 (#24210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 07:46:37 +01:00
renovate[bot]
56ab29da81 Update formatjs monorepo (#24195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 18:59:29 +01:00
Paul Bottein
10abaa538d Improve tile card interactions (#24175)
* Use none instead of more info for icon

* Improve tile icon accessibility

* Remove background shape for tile card icon when no action

* Add hover opacity

* Fix wrong type

* Remove padding around icon and increase hover opacity
2025-02-12 10:49:31 +01:00
ildar170975
f25dac7f68 Settings -> Automations: show a title for "State" column (#23977)
show a title for "State" column
2025-02-12 09:46:40 +01:00
karwosts
99065a689f Retry subscribing to weather forecast if it fails (#24188) 2025-02-12 08:39:46 +02:00
renovate[bot]
ac88d5993a Update dependency @lokalise/node-api to v13.1.0 (#24191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 23:04:02 +01:00
Paul Bottein
b09ce45d31 Display hold and double tap actions in tile card editor if they are set (#24178) 2025-02-11 16:45:48 +01:00
Paul Bottein
78e2809fe7 Fix default value for color in entity badge editor (#24186) 2025-02-11 16:14:04 +02:00
renovate[bot]
a631bf9854 Update babel monorepo to v7.26.8 (#24183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 13:53:07 +01:00
78 changed files with 2667 additions and 1764 deletions

View File

@@ -5,7 +5,7 @@
"context": ".."
},
"appPort": "8124:8123",
"postCreateCommand": "sudo apt update && sudo apt upgrade -y && sudo apt install -y libpcap-dev",
"postCreateCommand": "./.devcontainer/post_create.sh",
"postStartCommand": "script/bootstrap",
"containerEnv": {
"DEV_CONTAINER": "1",

22
.devcontainer/post_create.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# This script will run after the container is created
# add github cli
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
# Update package lists
sudo apt-get update
sudo apt upgrade -y
# Install necessary packages
sudo apt-get install -y libpcap-dev gh
# Display a message
echo "Post-create script has been executed successfully."

42
.vscode/tasks.json vendored
View File

@@ -1,6 +1,42 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Develop and serve Frontend",
"type": "shell",
"command": "script/develop_and_serve -c ${input:coreUrl}",
// Sync changes here to other tasks until issue resolved
// https://github.com/Microsoft/vscode/issues/61497
"problemMatcher": {
"owner": "ha-build",
"source": "ha-build",
"fileLocation": "absolute",
"severity": "error",
"pattern": [
{
"regexp": "(SyntaxError): (.+): (.+) \\((\\d+):(\\d+)\\)",
"severity": 1,
"file": 2,
"message": 3,
"line": 4,
"column": 5
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "Changes detected. Starting compilation",
"endsPattern": "Build done @"
}
},
"isBackground": true,
"group": {
"kind": "build",
"isDefault": true
},
"runOptions": {
"instanceLimit": 1
}
},
{
"label": "Develop Frontend",
"type": "gulp",
@@ -241,6 +277,12 @@
"id": "supervisorToken",
"type": "promptString",
"description": "The token for the Remote API proxy add-on"
},
{
"id": "coreUrl",
"type": "promptString",
"description": "The URL of the Home Assistant Core instance",
"default": "http://127.0.0.1:8123"
}
]
}

View File

@@ -1,16 +1,16 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default [
...rootConfig,
{
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
},
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
},
];
});

View File

@@ -90,6 +90,14 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/leaflet.css"),
staticPath("images/leaflet/")
);
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.css"),
staticPath("images/leaflet/")
);
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.Default.css"),
staticPath("images/leaflet/")
);
fs.copySync(
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")

View File

@@ -1,11 +1,16 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -15,17 +20,14 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
export default [
...compat.extends(
"airbnb-base",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/strict",
"plugin:@typescript-eslint/stylistic",
"plugin:wc/recommended",
"plugin:lit/all",
"plugin:lit-a11y/recommended",
"prettier"
),
export default tseslint.config(
...compat.extends("airbnb-base", "plugin:lit-a11y/recommended"),
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
{
plugins: {
"unused-imports": unusedImports,
@@ -43,7 +45,7 @@ export default [
Polymer: true,
},
parser: tsParser,
parser: tseslint.parser,
ecmaVersion: 2020,
sourceType: "module",
@@ -184,5 +186,5 @@ export default [
],
"no-use-before-define": "off",
},
},
];
}
);

View File

@@ -1,10 +1,10 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default [
...rootConfig,
{
rules: {
"no-console": "off",
},
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
},
];
});

View File

@@ -26,25 +26,25 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.26.7",
"@babel/runtime": "7.26.9",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.4",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.0",
"@codemirror/language": "6.10.8",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.8",
"@codemirror/legacy-modes": "6.4.3",
"@codemirror/search": "6.5.9",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.2",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.2",
"@formatjs/intl-displaynames": "6.8.9",
"@formatjs/intl-durationformat": "0.7.2",
"@formatjs/intl-datetimeformat": "6.17.3",
"@formatjs/intl-displaynames": "6.8.10",
"@formatjs/intl-durationformat": "0.7.3",
"@formatjs/intl-getcanonicallocales": "2.5.4",
"@formatjs/intl-listformat": "7.7.9",
"@formatjs/intl-locale": "4.2.9",
"@formatjs/intl-numberformat": "8.15.2",
"@formatjs/intl-pluralrules": "5.4.2",
"@formatjs/intl-relativetimeformat": "11.4.9",
"@formatjs/intl-listformat": "7.7.10",
"@formatjs/intl-locale": "4.2.10",
"@formatjs/intl-numberformat": "8.15.3",
"@formatjs/intl-pluralrules": "5.4.3",
"@formatjs/intl-relativetimeformat": "11.4.10",
"@fullcalendar/core": "6.1.15",
"@fullcalendar/daygrid": "6.1.15",
"@fullcalendar/interaction": "6.1.15",
@@ -53,9 +53,9 @@
"@fullcalendar/timegrid": "6.1.15",
"@lezer/highlight": "1.2.1",
"@lit-labs/context": "0.4.1",
"@lit-labs/motion": "1.0.7",
"@lit-labs/observers": "2.0.4",
"@lit-labs/virtualizer": "2.0.15",
"@lit-labs/motion": "1.0.8",
"@lit-labs/observers": "2.0.5",
"@lit-labs/virtualizer": "2.1.0",
"@lrnwebcomponents/simple-tooltip": "8.0.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
@@ -91,8 +91,8 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vaadin/combo-box": "24.6.5",
"@vaadin/vaadin-themable-mixin": "24.6.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.9",
@@ -116,16 +116,18 @@
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.7.14",
"intl-messageformat": "10.7.15",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "2.8.0",
"lit-html": "2.8.0",
"luxon": "3.5.0",
"marked": "15.0.6",
"marked": "15.0.7",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
"qrcode": "1.5.4",
@@ -137,7 +139,7 @@
"tinykeys": "3.0.0",
"tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0",
"ua-parser-js": "2.0.1",
"ua-parser-js": "2.0.2",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
@@ -152,20 +154,20 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.26.7",
"@babel/core": "7.26.9",
"@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/preset-env": "7.26.7",
"@babel/plugin-transform-runtime": "7.26.9",
"@babel/preset-env": "7.26.9",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.0.0",
"@octokit/auth-oauth-device": "7.1.2",
"@octokit/plugin-retry": "7.1.3",
"@octokit/rest": "21.1.0",
"@lokalise/node-api": "13.1.0",
"@octokit/auth-oauth-device": "7.1.3",
"@octokit/plugin-retry": "7.1.4",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.2.2",
"@rspack/core": "1.2.2",
"@rspack/cli": "1.2.3",
"@rspack/core": "1.2.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
@@ -175,6 +177,7 @@
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.16",
"@types/leaflet-draw": "1.0.11",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.4.2",
"@types/mocha": "10.0.10",
@@ -183,14 +186,12 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "8.23.0",
"@typescript-eslint/parser": "8.23.0",
"@vitest/coverage-v8": "3.0.5",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.20.0",
"eslint": "9.20.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10",
@@ -215,9 +216,8 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"object-hash": "3.0.0",
"pinst": "3.0.0",
"prettier": "3.4.2",
"prettier": "3.5.1",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "19.0.2",
@@ -225,6 +225,7 @@
"terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
@@ -239,7 +240,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.14.0",
"globals": "15.15.0",
"tslib": "2.8.1"
},
"packageManager": "yarn@4.6.0"

View File

@@ -136,11 +136,18 @@ export function theme2hex(themeColor: string): string {
}
const rgbFromColorName = colors[themeColor];
if (!rgbFromColorName) {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
}
return rgb2hex(rgbFromColorName);
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
}
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
}

View File

@@ -16,11 +16,30 @@ export const setupLeafletMap = async (
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(style);
const markerClusterStyle = document.createElement("link");
markerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.css"
);
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
const defaultMarkerClusterStyle = document.createElement("link");
defaultMarkerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.Default.css"
);
defaultMarkerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(defaultMarkerClusterStyle);
map.setView([52.3731339, 4.8903147], 13);
const tileLayer = createTileLayer(Leaflet).addTo(map);

View File

@@ -1,6 +1,9 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { UNAVAILABLE_STATES } from "../../data/entity";
import type { HomeAssistant } from "../../types";
import { computeDomain } from "./compute_domain";
import { stringCompare } from "../string/compare";
export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [
@@ -237,6 +240,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
};
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
@@ -269,7 +273,19 @@ export const getStates = (
case "device_tracker":
case "person":
if (!attribute) {
result.push("home", "not_home");
result.push(
...Object.entries(hass.states)
.filter(
([entityId, stateObj]) =>
computeDomain(entityId) === "zone" &&
entityId !== "zone.home" &&
stateObj.attributes.friendly_name
)
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
);
}
break;
case "event":

View File

@@ -0,0 +1,32 @@
import type { LatLngExpression, Layer, Map, MarkerOptions } from "leaflet";
import { Marker } from "leaflet";
export class DecoratedMarker extends Marker {
decorationLayer: Layer | undefined;
constructor(
latlng: LatLngExpression,
decorationLayer?: Layer,
options?: MarkerOptions
) {
super(latlng, options);
this.decorationLayer = decorationLayer;
}
onAdd(map: Map) {
super.onAdd(map);
// If decoration has been provided, add it to the map as well
this.decorationLayer?.addTo(map);
return this;
}
onRemove(map: Map) {
// If decoration has been provided, remove it from the map as well
this.decorationLayer?.remove();
return super.onRemove(map);
}
}

View File

@@ -24,6 +24,7 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@@ -67,12 +68,16 @@ export class HaChartBase extends LitElement {
private _listeners: (() => void)[] = [];
private _originalZrFlush?: () => void;
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
public connectedCallback() {
@@ -83,19 +88,19 @@ export class HaChartBase extends LitElement {
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion });
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
this._setChartOptions({ animation: !this._reducedMotion });
}
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if ((isMac && ev.metaKey) || (!isMac && ev.ctrlKey)) {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
}
};
@@ -104,9 +109,7 @@ export class HaChartBase extends LitElement {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
}
};
@@ -124,27 +127,24 @@ export class HaChartBase extends LitElement {
}
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.hasUpdated || !this.chart) {
if (!this.chart) {
return;
}
if (changedProps.has("_themes")) {
this._setupChart();
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("data")) {
this.chart.setOption(
{ series: this.data },
{ lazyUpdate: true, replaceMerge: ["series"] }
);
chartOptions.series = this.data;
}
if (changedProps.has("options") || changedProps.has("_isZoomed")) {
this.chart.setOption(this._createOptions(), {
lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom
replaceMerge: ["grid"],
});
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
}
}
@@ -158,7 +158,6 @@ export class HaChartBase extends LitElement {
style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`,
})}
@wheel=${this._handleWheel}
>
<div class="chart"></div>
${this._isZoomed
@@ -240,8 +239,8 @@ export class HaChartBase extends LitElement {
type: "inside",
orient: "horizontal",
filterMode: "none",
moveOnMouseMove: this._isZoomed,
preventDefaultMouseMove: this._isZoomed,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
zoomLock: !this._isTouchDevice && !this._modifierPressed,
};
}
@@ -512,25 +511,33 @@ export class HaChartBase extends LitElement {
return Math.max(this.clientWidth / 2, 200);
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
this._modifierPressed = false;
}
private _handleWheel(e: WheelEvent) {
// if the window is not focused, we don't receive the keydown events but scroll still works
if (!this.options?.dataZoom) {
const modifierPressed = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (modifierPressed) {
e.preventDefault();
}
if (modifierPressed !== this._modifierPressed) {
this._modifierPressed = modifierPressed;
this.chart?.setOption({
dataZoom: this._getDataZoomConfig(),
});
private _setChartOptions(options: ECOption) {
if (!this.chart) {
return;
}
if (!this._originalZrFlush) {
const dataSize = ensureArray(this.data).reduce(
(acc, series) => acc + (series.data as any[]).length,
0
);
if (dataSize > 10000) {
// for large datasets zr.flush takes 30-40% of the render time
// so we delay it a bit to avoid blocking the main thread
const zr = this.chart.getZr();
this._originalZrFlush = zr.flush.bind(zr);
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.();
}, 10);
};
}
}
const replaceMerge = options.series ? ["series"] : [];
this.chart.setOption(options, { replaceMerge });
}
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
static styles = css`

View File

@@ -75,6 +75,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _yWidth = 25;
@state() private _visualMap?: VisualMapComponentOption[];
private _chartTime: Date = new Date();
protected render() {
@@ -92,7 +94,7 @@ export class StateHistoryChartLine extends LitElement {
`;
}
private _renderTooltip(params: any) {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
@@ -115,7 +117,7 @@ export class StateHistoryChartLine extends LitElement {
return;
}
// If the datapoint is not found, we need to find the last datapoint before the current time
let lastData;
let lastData: any;
const data = dataset.data || [];
for (let i = data.length - 1; i >= 0; i--) {
const point = data[i];
@@ -175,7 +177,7 @@ export class StateHistoryChartLine extends LitElement {
})
.join("<br>")
);
}
};
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
@@ -208,8 +210,8 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("_chartData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth")
) {
const rtl = computeRTL(this.hass);
@@ -280,37 +282,11 @@ export class StateHistoryChartLine extends LitElement {
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30,
},
visualMap: this._chartData
.map((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return false;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
return {
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
};
})
.filter(Boolean) as VisualMapComponentOption[],
visualMap: this._visualMap,
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip.bind(this),
formatter: this._renderTooltip,
},
};
}
@@ -725,6 +701,33 @@ export class StateHistoryChartLine extends LitElement {
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
const visualMap: VisualMapComponentOption[] = [];
this._chartData.forEach((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
}
private _clampYAxis(value?: number | ((values: any) => number)) {

View File

@@ -273,11 +273,13 @@ export class StatisticsChart extends LitElement {
this._chartOptions = {
xAxis: [
{
id: "xAxis",
type: "time",
min: startTime,
max: endTime,
max: this.endTime,
},
{
id: "hiddenAxis",
type: "time",
show: false,
},
@@ -368,7 +370,6 @@ export class StatisticsChart extends LitElement {
if (endTime > new Date()) {
endTime = new Date();
}
this.endTime = endTime;
let unit: string | undefined | null;

View File

@@ -448,6 +448,7 @@ export class HaDataTable extends LitElement {
)}
@click=${this._handleHeaderClick}
.columnId=${key}
title=${ifDefined(column.title)}
>
${column.sortable
? html`

View File

@@ -57,7 +57,7 @@ class HaEntityStatePicker extends LitElement {
(this._comboBox as any).items = [
...(this.extraOptions ?? []),
...(this.entityId && stateObj
? getStates(stateObj, this.attribute).map((key) => ({
? getStates(this.hass, stateObj, this.attribute).map((key) => ({
value: key,
label: !this.attribute
? this.hass.formatEntityState(stateObj, key)

View File

@@ -5,15 +5,16 @@ import "@material/mwc-list/mwc-list-item";
import { mdiCalendar } from "@mdi/js";
import {
addDays,
subHours,
endOfDay,
endOfMonth,
endOfWeek,
endOfYear,
isThisYear,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
isThisYear,
} from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit";
@@ -178,6 +179,96 @@ export class HaDateRangePicker extends LitElement {
weekStartsOn,
}),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-1h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
1
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-12h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
12
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-24h"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-7d"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24 * 7
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
[this.hass.localize(
"ui.components.date-range-picker.ranges.now-30d"
)]: [
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
24 * 30
),
calcDate(
today,
subHours,
this.hass.locale,
this.hass.config,
0
),
],
}
: {}),
};
@@ -395,44 +486,55 @@ export class HaDateRangePicker extends LitElement {
}
static styles = css`
ha-icon-button {
direction: var(--direction);
}
ha-icon-button {
direction: var(--direction);
.date-range-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
.date-range-inputs {
display: flex;
align-items: center;
gap: 8px;
}
}
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: 1px solid var(--divider-color);
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
@media only screen and (max-height: 940px) and (max-width: 800px) {
.date-range-ranges {
overflow: auto;
max-height: calc(70vh - 330px);
min-height: 160px;
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
:host([header-position]) .date-range-ranges {
max-height: calc(90vh - 430px);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%
}
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
`;
}
`;
}
declare global {

View File

@@ -1,7 +1,12 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager";
const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = {
reType:
@@ -26,6 +31,16 @@ class HaMarkdownElement extends ReactiveElement {
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
false;
@property({ type: Boolean }) public cache = false;
public disconnectedCallback() {
super.disconnectedCallback();
if (this.cache) {
const key = this._computeCacheKey();
markdownCache.set(key, this.innerHTML);
}
}
protected createRenderRoot() {
return this;
}
@@ -37,6 +52,24 @@ class HaMarkdownElement extends ReactiveElement {
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey();
if (markdownCache.has(key)) {
this.innerHTML = markdownCache.get(key)!;
this._resize();
}
}
}
private _computeCacheKey() {
return hash({
content: this.content,
allowSvg: this.allowSvg,
breaks: this.breaks,
});
}
private async _render() {
this.innerHTML = await renderMarkdown(
String(this.content),

View File

@@ -13,6 +13,8 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
false;
@property({ type: Boolean }) public cache = false;
protected render() {
if (!this.content) {
return nothing;
@@ -23,6 +25,7 @@ export class HaMarkdown extends LitElement {
.allowSvg=${this.allowSvg}
.breaks=${this.breaks}
.lazyImages=${this.lazyImages}
.cache=${this.cache}
></ha-markdown-element>`;
}

View File

@@ -8,9 +8,10 @@ import type {
Map,
Marker,
Polyline,
MarkerClusterGroup,
} from "leaflet";
import type { PropertyValues } from "lit";
import { ReactiveElement, css } from "lit";
import { css, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDateTime } from "../../common/datetime/format_date_time";
@@ -26,6 +27,7 @@ import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { DecoratedMarker } from "../../common/map/decorated_marker";
declare global {
// for fire event
@@ -84,6 +86,9 @@ export class HaMap extends ReactiveElement {
@property({ type: Number }) public zoom = 14;
@property({ attribute: "cluster-markers", type: Boolean })
public clusterMarkers = true;
@state() private _loaded = false;
public leafletMap?: Map;
@@ -96,10 +101,12 @@ export class HaMap extends ReactiveElement {
private _mapFocusItems: (Marker | Circle)[] = [];
private _mapZones: (Marker | Circle)[] = [];
private _mapZones: DecoratedMarker[] = [];
private _mapFocusZones: (Marker | Circle)[] = [];
private _mapCluster: MarkerClusterGroup | undefined;
private _mapPaths: (Polyline | CircleMarker)[] = [];
private _clickCount = 0;
@@ -151,6 +158,10 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
@@ -175,6 +186,7 @@ export class HaMap extends ReactiveElement {
) {
return;
}
this._updateMapStyle();
}
@@ -426,6 +438,11 @@ export class HaMap extends ReactiveElement {
this._mapFocusZones = [];
}
if (this._mapCluster) {
this._mapCluster.remove();
this._mapCluster = undefined;
}
if (!this.entities) {
return;
}
@@ -481,26 +498,24 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML;
}
// create marker with the icon
this._mapZones.push(
Leaflet.marker([latitude, longitude], {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className,
}),
interactive: this.interactiveZones,
title,
})
);
// create circle around it
const circle = Leaflet.circle([latitude, longitude], {
interactive: false,
color: passive ? passiveZoneColor : zoneColor,
radius,
});
this._mapZones.push(circle);
const marker = new DecoratedMarker([latitude, longitude], circle, {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
className,
}),
interactive: this.interactiveZones,
title,
});
this._mapZones.push(marker);
if (
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
@@ -538,7 +553,7 @@ export class HaMap extends ReactiveElement {
}
// create marker with the icon
const marker = Leaflet.marker([latitude, longitude], {
const marker = new DecoratedMarker([latitude, longitude], undefined, {
icon: Leaflet.divIcon({
html: entityMarker,
iconSize: [48, 48],
@@ -546,24 +561,33 @@ export class HaMap extends ReactiveElement {
}),
title: title,
});
this._mapItems.push(marker);
if (typeof entity === "string" || entity.focus !== false) {
this._mapFocusItems.push(marker);
}
// create circle around if entity has accuracy
if (gpsAccuracy) {
this._mapItems.push(
Leaflet.circle([latitude, longitude], {
interactive: false,
color: darkPrimaryColor,
radius: gpsAccuracy,
})
);
marker.decorationLayer = Leaflet.circle([latitude, longitude], {
interactive: false,
color: darkPrimaryColor,
radius: gpsAccuracy,
});
}
this._mapItems.push(marker);
}
if (this.clusterMarkers) {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
} else {
this._mapItems.forEach((marker) => map.addLayer(marker));
}
this._mapItems.forEach((marker) => map.addLayer(marker));
this._mapZones.forEach((marker) => map.addLayer(marker));
}

View File

@@ -1,25 +1,81 @@
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import "../ha-icon";
import "../ha-svg-icon";
import { classMap } from "lit/directives/class-map";
export type TileIconImageStyle = "square" | "rounded-square" | "circle";
export const DEFAULT_TILE_ICON_BORDER_STYLE = "circle";
@customElement("ha-tile-icon")
export class HaTileIcon extends LitElement {
@property({ type: Boolean, reflect: true })
public interactive = false;
@property({ attribute: "border-style", type: String })
public imageStyle?: TileIconImageStyle;
@property({ attribute: false })
public imageUrl?: string;
protected render(): TemplateResult {
return html`
<div class="shape">
if (this.imageUrl) {
const imageStyle = this.imageStyle || DEFAULT_TILE_ICON_BORDER_STYLE;
return html`
<div class="container ${classMap({ [imageStyle]: this.imageUrl })}">
<img alt="" src=${this.imageUrl} />
</div>
<slot></slot>
`;
}
return html`
<div class="container ${this.interactive ? "background" : ""}">
<slot name="icon"></slot>
</div>
<slot></slot>
`;
}
static styles = css`
:host {
--tile-icon-color: var(--disabled-color);
--mdc-icon-size: 22px;
--tile-icon-opacity: 0.2;
--tile-icon-hover-opacity: 0.35;
--mdc-icon-size: 24px;
position: relative;
user-select: none;
transition: transform 180ms ease-in-out;
}
.shape::before {
:host([interactive]:active) {
transform: scale(1.2);
}
:host([interactive]:hover) {
--tile-icon-opacity: var(--tile-icon-hover-opacity);
}
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
overflow: hidden;
transition: box-shadow 180ms ease-in-out;
}
:host([interactive]:focus-visible) .container {
box-shadow: 0 0 0 2px var(--tile-icon-color);
}
.container.rounded-square {
border-radius: 8px;
}
.container.square {
border-radius: 0;
}
.container.background::before {
content: "";
position: absolute;
top: 0;
@@ -27,24 +83,21 @@ export class HaTileIcon extends LitElement {
height: 100%;
width: 100%;
background-color: var(--tile-icon-color);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--tile-icon-opacity);
}
.shape {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: color 180ms ease-in-out;
overflow: hidden;
}
.shape ::slotted(*) {
.container ::slotted([slot="icon"]) {
display: flex;
color: var(--tile-icon-color);
transition: color 180ms ease-in-out;
pointer-events: none;
}
.container img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
}

View File

@@ -1,53 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
export type TileImageStyle = "square" | "rounded-square" | "circle";
@customElement("ha-tile-image")
export class HaTileImage extends LitElement {
@property({ attribute: false }) public imageUrl?: string;
@property({ attribute: false }) public imageAlt?: string;
@property({ attribute: false }) public imageStyle: TileImageStyle = "circle";
protected render() {
return html`
<div class="image ${this.imageStyle}">
${this.imageUrl
? html`<img alt=${ifDefined(this.imageAlt)} src=${this.imageUrl} />`
: nothing}
</div>
`;
}
static styles = css`
.image {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image.rounded-square {
border-radius: 8%;
}
.image.square {
border-radius: 0;
}
.image img {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-tile-image": HaTileImage;
}
}

View File

@@ -12,6 +12,7 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api";
@@ -130,7 +131,13 @@ export interface BackupContentExtended extends BackupContent, BackupData {}
export interface BackupInfo {
backups: BackupContent[];
backing_up: boolean;
agent_errors: Record<string, string>;
last_attempted_automatic_backup: string | null;
last_completed_automatic_backup: string | null;
last_non_idle_event: ManagerStateEvent | null;
next_automatic_backup: string | null;
next_automatic_backup_additional: boolean;
state: BackupManagerState;
}
export interface BackupDetails {

View File

@@ -233,11 +233,11 @@ export const restoreBackup = async (
type: HassioBackupDetail["type"],
backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useSnapshotUrl: boolean
useBackupUrl: boolean
): Promise<void> => {
await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
`hassio/${useBackupUrl ? "backups" : "snapshots"}/${backupSlug}/restore/${type}`,
backupDetails
);
};

View File

@@ -1,11 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { HomeAssistant, ThemeSettings } from "../types";
import {
fetchFrontendUserData,
saveFrontendUserData,
subscribeFrontendUserData,
} from "./frontend";
export interface ThemeVars {
// Incomplete
@@ -56,16 +50,3 @@ export const subscribeThemes = (
conn,
onChange
);
export const SELECTED_THEME_KEY = "selectedTheme";
export const saveSelectedTheme = (hass: HomeAssistant, data?: ThemeSettings) =>
saveFrontendUserData(hass.connection, SELECTED_THEME_KEY, data);
export const subscribeSelectedTheme = (
hass: HomeAssistant,
callback: (selectedTheme?: ThemeSettings | null) => void
) => subscribeFrontendUserData(hass.connection, SELECTED_THEME_KEY, callback);
export const fetchSelectedTheme = (hass: HomeAssistant) =>
fetchFrontendUserData(hass.connection, SELECTED_THEME_KEY);

View File

@@ -85,6 +85,7 @@ class StepFlowCreateEntry extends LitElement {
assistSatelliteSupportsSetupFlow(this.hass.states[satellite.entity_id])
)
) {
this.navigateToResult = false;
this._flowDone();
showVoiceAssistantSetupDialog(this, {
deviceId: devices[0].id,

View File

@@ -49,6 +49,8 @@ class LightRgbColorPicker extends LitElement {
@state() private _hsPickerValue?: [number, number];
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
@@ -211,7 +213,10 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("entityId") && !changedProps.has("hass")) {
if (
this._isInteracting ||
(!changedProps.has("entityId") && !changedProps.has("hass"))
) {
return;
}
@@ -219,10 +224,13 @@ class LightRgbColorPicker extends LitElement {
}
private _hsColorCursorMoved(ev: CustomEvent) {
if (!ev.detail.value) {
const color = ev.detail.value;
this._isInteracting = color !== undefined;
if (color === undefined) {
return;
}
this._hsPickerValue = ev.detail.value;
this._hsPickerValue = color;
this._throttleUpdateColor();
}

View File

@@ -22,7 +22,6 @@ import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity_attributes";
declare global {
interface HASSDomEvents {
"color-changed": LightColor;
"color-hovered": LightColor | undefined;
}
}
@@ -54,6 +53,8 @@ class LightColorTempPicker extends LitElement {
@state() private _ctPickerValue?: number;
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
@@ -113,7 +114,7 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("stateObj")) {
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
@@ -123,16 +124,14 @@ class LightColorTempPicker extends LitElement {
private _ctColorCursorMoved(ev: CustomEvent) {
const ct = ev.detail.value;
this._isInteracting = ct !== undefined;
if (isNaN(ct) || this._ctPickerValue === ct) {
return;
}
this._ctPickerValue = ct;
fireEvent(this, "color-hovered", {
color_temp_kelvin: ct,
});
this._throttleUpdateColorTemp();
}
@@ -143,8 +142,6 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value;
fireEvent(this, "color-hovered", undefined);
if (isNaN(ct) || this._ctPickerValue === ct) {
return;
}

View File

@@ -20,12 +20,6 @@
<meta name="color-scheme" content="dark light" />
<%= renderTemplate("_style_base.html.template") %>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);

View File

@@ -1,5 +1,4 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiAlertCircleCheck,
mdiArrowDown,
@@ -27,7 +26,9 @@ import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
@@ -240,89 +241,104 @@ export default class HaAutomationActionRow extends LitElement {
</div> `
: nothing}
<ha-button-menu
<ha-md-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
fixed
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
<ha-md-menu-item .clickAction=${this._runAction}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.run"
)}
<ha-svg-icon slot="graphic" .path=${mdiPlay}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._renameAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._duplicateAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._copyAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._cutAction}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${!this._uiModeAvailable}>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this._uiModeAvailable}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.action.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
@@ -331,15 +347,15 @@ export default class HaAutomationActionRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${this.action.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
graphic="icon"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -347,11 +363,11 @@ export default class HaAutomationActionRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="graphic"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
@@ -424,47 +440,6 @@ export default class HaAutomationActionRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._runAction();
break;
case 1:
await this._renameAction();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -472,16 +447,16 @@ export default class HaAutomationActionRow extends LitElement {
};
}
private _onDisable() {
private _onDisable = () => {
const enabled = !(this.action.enabled ?? true);
const value = { ...this.action, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
}
};
private async _runAction() {
private _runAction = async () => {
const validated = await validateConfig(this.hass, {
actions: this.action,
});
@@ -513,9 +488,9 @@ export default class HaAutomationActionRow extends LitElement {
"ui.panel.config.automation.editor.actions.run_action_success"
),
});
}
};
private _onDelete() {
private _onDelete = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete_confirm_title"
@@ -530,7 +505,7 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
}
};
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
@@ -561,7 +536,7 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlMode = true;
}
private async _renameAction(): Promise<void> {
private _renameAction = async (): Promise<void> => {
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.change_alias"
@@ -598,7 +573,37 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlEditor?.setValue(value);
}
}
}
};
private _duplicateAction = () => {
fireEvent(this, "duplicate");
};
private _copyAction = () => {
this._setClipboard();
};
private _cutAction = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
public expand() {
this.updateComplete.then(() => {
@@ -610,7 +615,6 @@ export default class HaAutomationActionRow extends LitElement {
return [
haStyle,
css`
ha-button-menu,
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
@@ -649,18 +653,11 @@ export default class HaAutomationActionRow extends LitElement {
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
}
mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
mwc-list-item.hidden {
display: none;
}
.warning ul {
margin: 4px 0;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
`,
];

View File

@@ -1,5 +1,4 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiArrowDown,
mdiArrowUp,
@@ -24,11 +23,12 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import type {
AutomationClipboard,
Condition,
@@ -141,12 +141,12 @@ export default class HaAutomationConditionRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-button-menu
<ha-md-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
fixed
positioning="fixed"
>
<ha-icon-button
slot="trigger"
@@ -155,76 +155,91 @@ export default class HaAutomationConditionRow extends LitElement {
>
</ha-icon-button>
<ha-list-item graphic="icon">
<ha-md-menu-item .clickAction=${this._testCondition}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)}
<ha-svg-icon slot="graphic" .path=${mdiFlask}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._duplicateCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._copyCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._cutCondition}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this._warnings}>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
${this.condition.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
@@ -233,15 +248,15 @@ export default class HaAutomationConditionRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${this.condition.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
graphic="icon"
.clickAction=${this._onDelete}
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -249,11 +264,11 @@ export default class HaAutomationConditionRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="graphic"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
@@ -325,47 +340,6 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._testCondition();
break;
case 1:
await this._renameCondition();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -373,13 +347,13 @@ export default class HaAutomationConditionRow extends LitElement {
};
}
private _onDisable() {
private _onDisable = () => {
const enabled = !(this.condition.enabled ?? true);
const value = { ...this.condition, enabled };
fireEvent(this, "value-changed", { value });
}
};
private _onDelete() {
private _onDelete = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.delete_confirm_title"
@@ -394,7 +368,7 @@ export default class HaAutomationConditionRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
}
};
private _switchUiMode() {
this._warnings = undefined;
@@ -406,7 +380,7 @@ export default class HaAutomationConditionRow extends LitElement {
this._yamlMode = true;
}
private async _testCondition() {
private _testCondition = async () => {
if (this._testing) {
return;
}
@@ -461,9 +435,9 @@ export default class HaAutomationConditionRow extends LitElement {
this._testing = false;
}, 2500);
}
}
};
private async _renameCondition(): Promise<void> {
private _renameCondition = async (): Promise<void> => {
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.change_alias"
@@ -489,7 +463,37 @@ export default class HaAutomationConditionRow extends LitElement {
value,
});
}
}
};
private _duplicateCondition = () => {
fireEvent(this, "duplicate");
};
private _copyCondition = () => {
this._setClipboard();
};
private _cutCondition = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
public expand() {
this.updateComplete.then(() => {
@@ -501,9 +505,6 @@ export default class HaAutomationConditionRow extends LitElement {
return [
haStyle,
css`
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
@@ -539,12 +540,6 @@ export default class HaAutomationConditionRow extends LitElement {
border-top-right-radius: var(--ha-card-border-radius, 12px);
border-top-left-radius: var(--ha-card-border-radius, 12px);
}
ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-list-item.hidden {
display: none;
}
.testing {
position: absolute;
top: 0px;
@@ -571,8 +566,8 @@ export default class HaAutomationConditionRow extends LitElement {
.testing.pass {
background-color: var(--success-color);
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
`,
];

View File

@@ -339,9 +339,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
sortable: true,
groupable: true,
hidden: narrow,
title: "",
type: "overflow",
label: this.hass.localize("ui.panel.config.automation.picker.state"),
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-entity-toggle
.stateObj=${automation}

View File

@@ -1,5 +1,4 @@
import { consume } from "@lit-labs/context";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import {
mdiArrowDown,
mdiArrowUp,
@@ -28,7 +27,9 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { handleStructError } from "../../../../common/structs/handle-errors";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
@@ -169,12 +170,12 @@ export default class HaAutomationTriggerRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-button-menu
<ha-md-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@keydown=${stopPropagation}
@closed=${stopPropagation}
fixed
positioning="fixed"
>
<ha-icon-button
slot="trigger"
@@ -182,84 +183,93 @@ export default class HaAutomationTriggerRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._renameTrigger}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon slot="graphic" .path=${mdiIdentifier}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._duplicateTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._copyTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${this.disabled}>
<ha-md-menu-item
.clickAction=${this._cutTrigger}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
)}
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
</ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon
></ha-list-item>
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-list-item graphic="icon" .disabled=${!supported}>
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!supported}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-md-menu-item>
<li divider role="separator"></li>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-list-item
graphic="icon"
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
@@ -270,16 +280,16 @@ export default class HaAutomationTriggerRow extends LitElement {
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="graphic"
slot="start"
.path=${"enabled" in this.trigger &&
this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -287,11 +297,11 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
<ha-svg-icon
class="warning"
slot="graphic"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-md-menu-item>
</ha-md-button-menu>
<div
class=${classMap({
@@ -464,48 +474,6 @@ export default class HaAutomationTriggerRow extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._renameTrigger();
break;
case 1:
this._requestShowId = true;
this.expand();
break;
case 2:
fireEvent(this, "duplicate");
break;
case 3:
this._setClipboard();
break;
case 4:
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
break;
case 5:
fireEvent(this, "move-up");
break;
case 6:
fireEvent(this, "move-down");
break;
case 7:
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
break;
case 8:
this._onDisable();
break;
case 9:
this._onDelete();
break;
}
}
private _setClipboard() {
this._clipboard = {
...this._clipboard,
@@ -513,7 +481,7 @@ export default class HaAutomationTriggerRow extends LitElement {
};
}
private _onDelete() {
private _onDelete = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.delete_confirm_title"
@@ -528,9 +496,9 @@ export default class HaAutomationTriggerRow extends LitElement {
fireEvent(this, "value-changed", { value: null });
},
});
}
};
private _onDisable() {
private _onDisable = () => {
if (isTriggerList(this.trigger)) return;
const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled };
@@ -538,7 +506,7 @@ export default class HaAutomationTriggerRow extends LitElement {
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
}
};
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
@@ -605,7 +573,7 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private async _renameTrigger(): Promise<void> {
private _renameTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, {
title: this.hass.localize(
@@ -636,7 +604,42 @@ export default class HaAutomationTriggerRow extends LitElement {
this._yamlEditor?.setValue(value);
}
}
}
};
private _showTriggerId = () => {
this._requestShowId = true;
this.expand();
};
private _duplicateTrigger = () => {
fireEvent(this, "duplicate");
};
private _copyTrigger = () => {
this._setClipboard();
};
private _cutTrigger = () => {
this._setClipboard();
fireEvent(this, "value-changed", { value: null });
};
private _moveUp = () => {
fireEvent(this, "move-up");
};
private _moveDown = () => {
fireEvent(this, "move-down");
};
private _toggleYamlMode = () => {
if (this._yamlMode) {
this._switchUiMode();
} else {
this._switchYamlMode();
}
this.expand();
};
public expand() {
this.updateComplete.then(() => {
@@ -648,9 +651,6 @@ export default class HaAutomationTriggerRow extends LitElement {
return [
haStyle,
css`
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
.disabled {
opacity: 0.5;
pointer-events: none;
@@ -714,18 +714,12 @@ export default class HaAutomationTriggerRow extends LitElement {
background-color: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
}
ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-list-item.hidden {
display: none;
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
ha-md-menu-item > ha-svg-icon {
--mdc-icon-size: 24px;
}
`,
];

View File

@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
@@ -22,7 +23,6 @@ import {
import type { CloudStatus } from "../../../../../data/cloud";
import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
import { navigate } from "../../../../../common/navigate";
const DEFAULT_AGENTS = [];

View File

@@ -46,7 +46,7 @@ enum BackupScheduleTime {
}
interface RetentionData {
type: "copies" | "days";
type: "copies" | "days" | "forever";
value: number;
}
@@ -55,7 +55,7 @@ const RETENTION_PRESETS: Record<
RetentionData
> = {
copies_3: { type: "copies", value: 3 },
forever: { type: "days", value: 0 },
forever: { type: "forever", value: 0 },
};
const SCHEDULE_OPTIONS = [
@@ -79,7 +79,10 @@ const computeRetentionPreset = (
data: RetentionData
): RetentionPreset | undefined => {
for (const [key, value] of Object.entries(RETENTION_PRESETS)) {
if (value.type === data.type && value.value === data.value) {
if (
value.type === data.type &&
(value.type === RetentionPreset.FOREVER || value.value === data.value)
) {
return key as RetentionPreset;
}
}
@@ -92,7 +95,7 @@ interface FormData {
time?: string | null;
days: BackupDay[];
retention: {
type: "copies" | "days";
type: "copies" | "days" | "forever";
value: number;
};
}
@@ -142,7 +145,12 @@ class HaBackupConfigSchedule extends LitElement {
? config.schedule.days
: [],
retention: {
type: config.retention.days != null ? "days" : "copies",
type:
config.retention.days === null && config.retention.copies === null
? "forever"
: config.retention.days != null
? "days"
: "copies",
value: config.retention.days ?? config.retention.copies ?? 3,
},
};
@@ -160,9 +168,11 @@ class HaBackupConfigSchedule extends LitElement {
: [],
},
retention:
data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
data.retention.type === "forever"
? { days: null, copies: null }
: data.retention.type === "days"
? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null },
};
fireEvent(this, "value-changed", { value: this.value });
@@ -481,9 +491,19 @@ class HaBackupConfigSchedule extends LitElement {
private _retentionPresetChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
const value = target.value as RetentionPreset;
let value = target.value as RetentionPreset;
// custom needs to have a type of days or copies, set it to default copies 3
if (
value === RetentionPreset.CUSTOM &&
this._retentionPreset === RetentionPreset.FOREVER
) {
this._retentionPreset = value;
value = RetentionPreset.COPIES_3;
} else {
this._retentionPreset = value;
}
this._retentionPreset = value;
if (value !== RetentionPreset.CUSTOM) {
const data = this._getData(this.value);
const retention = RETENTION_PRESETS[value];
@@ -493,7 +513,7 @@ class HaBackupConfigSchedule extends LitElement {
}
this._setData({
...data,
retention: RETENTION_PRESETS[value],
retention,
});
}
}
@@ -504,6 +524,7 @@ class HaBackupConfigSchedule extends LitElement {
const value = parseInt(target.value);
const clamped = clamp(value, MIN_VALUE, MAX_VALUE);
const data = this._getData(this.value);
target.value = clamped.toString();
this._setData({
...data,
retention: {

View File

@@ -8,7 +8,7 @@ import {
mdiUpload,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@@ -27,6 +27,7 @@ import type {
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-circular-progress";
import "../../../components/ha-fab";
import "../../../components/ha-filter-states";
import "../../../components/ha-icon";
@@ -460,7 +461,17 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
extended
@click=${this._newBackup}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${backupInProgress
? html`<div slot="icon">
<ha-circular-progress
.size=${"small"}
indeterminate
></ha-circular-progress>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
`
: nothing}
@@ -605,7 +616,14 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
static get styles(): CSSResultGroup {
return haStyle;
return [
haStyle,
css`
ha-circular-progress {
--md-sys-color-primary: var(--mdc-theme-on-secondary);
}
`,
];
}
}

View File

@@ -8,6 +8,7 @@ import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-circular-progress";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
@@ -17,8 +18,10 @@ import type {
BackupAgent,
BackupConfig,
BackupContent,
BackupInfo,
} from "../../../data/backup";
import {
computeBackupAgentName,
generateBackup,
generateBackupWithAutomaticSettings,
} from "../../../data/backup";
@@ -50,6 +53,8 @@ class HaConfigBackupOverview extends LitElement {
@property({ attribute: false }) public manager!: ManagerStateEvent;
@property({ attribute: false }) public info?: BackupInfo;
@property({ attribute: false }) public backups: BackupContent[] = [];
@property({ attribute: false }) public fetching = false;
@@ -151,6 +156,26 @@ class HaConfigBackupOverview extends LitElement {
</ha-list-item>
</ha-button-menu>
<div class="content">
${this.info && Object.keys(this.info.agent_errors).length
? html`${Object.entries(this.info.agent_errors).map(
([agentId, error]) =>
html`<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.backup.overview.agent_error",
{
name: computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
),
}
)}
>
${error}
</ha-alert>`
)}`
: nothing}
${backupInProgress
? html`
<ha-backup-overview-progress
@@ -204,7 +229,14 @@ class HaConfigBackupOverview extends LitElement {
extended
@click=${this._newBackup}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${backupInProgress
? html`<div slot="icon">
<ha-circular-progress
.size=${"small"}
indeterminate
></ha-circular-progress>
</div>`
: html`<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>`}
</ha-fab>
</hass-subpage>
`;
@@ -231,6 +263,9 @@ class HaConfigBackupOverview extends LitElement {
padding-left: 0;
padding-right: 0;
}
ha-circular-progress {
--md-sys-color-primary: var(--mdc-theme-on-secondary);
}
`,
];
}

View File

@@ -1,4 +1,4 @@
import { mdiDotsVertical, mdiHarddisk } from "@mdi/js";
import { mdiDotsVertical, mdiHarddisk, mdiOpenInNew } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -28,6 +28,7 @@ import "./components/config/ha-backup-config-encryption-key";
import "./components/config/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule";
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("ha-config-backup-settings")
class HaConfigBackupSettings extends LitElement {
@@ -98,6 +99,8 @@ class HaConfigBackupSettings extends LitElement {
return nothing;
}
const supervisor = isComponentLoaded(this.hass, "hassio");
return html`
<hass-subpage
back-path="/config/backup"
@@ -105,7 +108,7 @@ class HaConfigBackupSettings extends LitElement {
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.settings.header")}
>
${isComponentLoaded(this.hass, "hassio")
${supervisor
? html`
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
@@ -203,6 +206,29 @@ class HaConfigBackupSettings extends LitElement {
`
: nothing}
</div>
<div class="card-actions">
<a
href=${documentationUrl(this.hass, "/integrations/#backup")}
target="_blank"
rel="noreferrer"
>
<ha-button>
<ha-svg-icon slot="icon" .path=${mdiOpenInNew}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.settings.locations.more_locations"
)}
</ha-button>
</a>
${supervisor
? html`<a href="/config/storage">
<ha-button>
${this.hass.localize(
"ui.panel.config.backup.settings.locations.manage_network_storage"
)}
</ha-button>
</a>`
: nothing}
</div>
</ha-card>
<ha-card>
<div class="card-header">
@@ -342,6 +368,9 @@ class HaConfigBackupSettings extends LitElement {
.card-content {
padding-bottom: 0;
}
a {
text-decoration: none;
}
`;
}

View File

@@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import type {
BackupAgent,
BackupConfig,
BackupContent,
BackupInfo,
} from "../../../data/backup";
import {
compareAgents,
@@ -44,7 +44,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE;
@state() private _backups: BackupContent[] = [];
@state() private _info?: BackupInfo;
@state() private _agents: BackupAgent[] = [];
@@ -87,8 +87,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
}
private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass);
this._backups = info.backups;
this._info = await fetchBackupInfo(this.hass);
}
private async _fetchBackupConfig() {
@@ -134,7 +133,8 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
pageEl.narrow = this.narrow;
pageEl.cloudStatus = this.cloudStatus;
pageEl.manager = this._manager;
pageEl.backups = this._backups;
pageEl.info = this._info;
pageEl.backups = this._info?.backups || [];
pageEl.config = this._config;
pageEl.agents = this._agents;
pageEl.fetching = this._fetching;

View File

@@ -355,6 +355,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
showNarrow: true,
sortable: true,
filterable: true,
minWidth: "80px",
maxWidth: "80px",
template: (entry) =>
entry.unavailable ||
entry.disabled_by ||

View File

@@ -346,9 +346,11 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
groupable: true,
},
editable: {
title: "",
label: localize("ui.panel.config.helpers.picker.headers.editable"),
title: localize("ui.panel.config.helpers.picker.headers.editable"),
type: "icon",
sortable: true,
minWidth: "88px",
maxWidth: "88px",
showNarrow: true,
template: (helper) => html`
${!helper.editable

View File

@@ -70,7 +70,7 @@ export class HaConfigFlowCard extends LitElement {
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
""
"/"
)}
rel="noreferrer"
target=${this.flow.context.configuration_url.startsWith(

View File

@@ -76,6 +76,8 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@state()
private _statistics?: ZWaveJSControllerStatisticsUpdatedMessage;
private _dialogOpen = false;
protected async firstUpdated() {
if (this.hass) {
await this._fetchData();
@@ -104,11 +106,17 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
),
subscribeS2Inclusion(this.hass, this.configEntryId, (message) => {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId,
dsk: message.dsk,
onStop: () => setTimeout(() => this._fetchData(), 100),
});
if (!this._dialogOpen) {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId,
dsk: message.dsk,
onStop: () => {
setTimeout(() => this._fetchData(), 100);
this._dialogOpen = false;
},
});
this._dialogOpen = true;
}
}),
];
}
@@ -570,11 +578,17 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
private async _addNodeClicked() {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId!,
// refresh the data after the dialog is closed. add a small delay for the inclusion state to update
onStop: () => setTimeout(() => this._fetchData(), 100),
});
if (!this._dialogOpen) {
showZWaveJSAddNodeDialog(this, {
entry_id: this.configEntryId!,
// refresh the data after the dialog is closed. add a small delay for the inclusion state to update
onStop: () => {
setTimeout(() => this._fetchData(), 100);
this._dialogOpen = false;
},
});
this._dialogOpen = true;
}
}
private async _removeNodeClicked() {

View File

@@ -706,7 +706,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
--data-table-border-width: 0;
}
:host(:not([narrow])) ha-data-table {
height: calc(100vh - 1px - var(--header-height));
height: calc(100vh - 1px - var(--header-height) - 48px);
display: block;
}

View File

@@ -20,40 +20,31 @@ export class HuiCardFeatures extends LitElement {
return nothing;
}
return html`
<div class="container">
${this.features.map(
(feature) => html`
<hui-card-feature
.hass=${this.hass}
.stateObj=${this.stateObj}
.color=${this.color}
.feature=${feature}
></hui-card-feature>
`
)}
</div>
${this.features.map(
(feature) => html`
<hui-card-feature
.hass=${this.hass}
.stateObj=${this.stateObj}
.color=${this.color}
.feature=${feature}
></hui-card-feature>
`
)}
`;
}
static styles = css`
:host {
--feature-color: var(--state-icon-color);
--feature-padding: 12px;
--feature-height: 42px;
--feature-border-radius: 12px;
--feature-button-spacing: 12px;
position: relative;
width: 100%;
}
.container {
position: relative;
display: flex;
flex-direction: column;
padding: var(--feature-padding);
padding-top: 0px;
gap: var(--feature-padding);
gap: 12px;
width: 100%;
height: 100%;
box-sizing: border-box;
justify-content: space-evenly;
}

View File

@@ -327,17 +327,19 @@ export class HuiEnergyDevicesDetailGraphCard
);
const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => {
const ts = Number(time);
const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: number[] = [ts, value];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(ts)).getTime();
}
untrackedConsumption.push(dataPoint);
});
Object.keys(consumptionData.total)
.sort((a, b) => Number(a) - Number(b))
.forEach((time) => {
const ts = Number(time);
const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: number[] = [ts, value];
if (compare) {
dataPoint[2] = dataPoint[0];
dataPoint[0] = compareTransform(new Date(ts)).getTime();
}
untrackedConsumption.push(dataPoint);
});
// random id to always add untracked at the end
const order = Date.now();
const dataset: BarSeriesOption = {
@@ -448,7 +450,15 @@ export class HuiEnergyDevicesDetailGraphCard
});
});
return sorted_devices
.map((device) => data.find((d) => (d.id as string).includes(device))!)
.map(
(device) =>
data.find((d) => {
const id = (d.id as string)
.replace(/^compare-/, "") // Remove compare- prefix
.replace(/-\d+$/, ""); // Remove numeric suffix
return id === device;
})!
)
.filter(Boolean);
}

View File

@@ -256,6 +256,7 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
hui-card-features {
width: 100%;
flex: none;
padding: 0 12px 12px 12px;
}
`;
}

View File

@@ -1,4 +1,8 @@
import { mdiImageFilterCenterFocus } from "@mdi/js";
import {
mdiDotsHexagon,
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { LatLngTuple } from "leaflet";
import type { PropertyValues } from "lit";
@@ -72,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@state() private _error?: { code: string; message: string };
@state() private _clusterMarkers = true;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
public setConfig(config: MapCardConfig): void {
@@ -170,18 +176,32 @@ class HuiMapCard extends LitElement implements LovelaceCard {
.autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones}
.themeMode=${themeMode}
.clusterMarkers=${this._clusterMarkers}
interactive-zones
render-passive
></ha-map>
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.reset_focus"
)}
.path=${mdiImageFilterCenterFocus}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._fitMap}
tabindex="0"
></ha-icon-button>
<div id="buttons">
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.toggle_grouping"
)}
.path=${this._clusterMarkers
? mdiGoogleCirclesCommunities
: mdiDotsHexagon}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._toggleClusterMarkers}
tabindex="0"
></ha-icon-button>
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.reset_focus"
)}
.path=${mdiImageFilterCenterFocus}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._fitMap}
tabindex="0"
></ha-icon-button>
</div>
</div>
</ha-card>
`;
@@ -320,6 +340,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._map?.fitMap();
}
private _toggleClusterMarkers() {
this._clusterMarkers = !this._clusterMarkers;
}
private _getColor(entityId: string): string {
let color = this._colorDict[entityId];
if (color) {
@@ -464,11 +488,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
overflow: hidden;
}
ha-icon-button {
#buttons {
position: absolute;
top: 75px;
left: 3px;
outline: none;
display: flex;
flex-direction: column;
}
#root {

View File

@@ -3,17 +3,21 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import hash from "object-hash";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-markdown";
import "../../../components/ha-alert";
import type { RenderTemplateResult } from "../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../data/ws-templates";
import type { HomeAssistant } from "../../../types";
import { CacheManager } from "../../../util/cache-manager";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { MarkdownCardConfig } from "./types";
const templateCache = new CacheManager<RenderTemplateResult>(1000);
@customElement("hui-markdown-card")
export class HuiMarkdownCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -68,9 +72,32 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
this._tryConnect();
}
private _computeCacheKey() {
return hash(this._config);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._tryDisconnect();
if (this._config && this._templateResult) {
const key = this._computeCacheKey();
templateCache.set(key, this._templateResult);
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
super.willUpdate(_changedProperties);
if (!this._config) {
return;
}
if (!this._templateResult) {
const key = this._computeCacheKey();
if (templateCache.has(key)) {
this._templateResult = templateCache.get(key);
}
}
}
protected render() {
@@ -87,6 +114,7 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
: nothing}
<ha-card .header=${this._config.title}>
<ha-markdown
cache
breaks
class=${classMap({
"no-header": !this._config.title,
@@ -107,7 +135,7 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
this._tryConnect();
}
const shouldBeHidden =
this._templateResult &&
!!this._templateResult &&
this._config.show_empty === false &&
this._templateResult.result.length === 0;
if (shouldBeHidden !== this.hidden) {

View File

@@ -327,7 +327,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
);
const endDate = this._energyEnd;
try {
let unitClass;
let unitClass: string | undefined | null;
if (this._config!.unit && this._metadata) {
const metadata = Object.values(this._metadata).find(
(metaData) =>

View File

@@ -248,6 +248,7 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
hui-card-features {
width: 100%;
flex: none;
padding: 0 12px 12px 12px;
}
`;
}

View File

@@ -18,8 +18,7 @@ import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
import "../../../components/tile/ha-tile-badge";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-image";
import type { TileImageStyle } from "../../../components/tile/ha-tile-image";
import type { TileIconImageStyle } from "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
@@ -36,7 +35,7 @@ import type {
LovelaceGridOptions,
} from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge";
import type { ThermostatCardConfig, TileCardConfig } from "./types";
import type { TileCardConfig } from "./types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
@@ -44,10 +43,10 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "more-info";
return supportsIconAction ? "toggle" : "none";
};
const DOMAIN_IMAGE_STYLE: Record<string, TileImageStyle> = {
const DOMAIN_IMAGE_SHAPE: Record<string, TileIconImageStyle> = {
update: "square",
media_player: "rounded-square",
};
@@ -84,7 +83,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@state() private _config?: TileCardConfig;
public setConfig(config: ThermostatCardConfig): void {
public setConfig(config: TileCardConfig): void {
if (!config.entity) {
throw new Error("Specify an entity");
}
@@ -101,10 +100,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(this._config?.vertical ? 1 : 0) +
(this._config?.features?.length || 0)
(featuresPosition === "inline" ? 0 : featuresCount)
);
}
@@ -112,9 +114,16 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const columns = 6;
let min_columns = 6;
let rows = 1;
if (this._config?.features?.length) {
rows += this._config.features.length;
const featurePosition = this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
} else {
rows += featuresCount;
}
}
if (this._config?.vertical) {
rows++;
min_columns = 3;
@@ -196,7 +205,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
);
get hasCardAction() {
private get _hasCardAction() {
return (
!this._config?.tap_action ||
hasAction(this._config?.tap_action) ||
@@ -205,12 +214,29 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
);
}
get hasIconAction() {
private get _hasIconAction() {
return (
!this._config?.icon_tap_action || hasAction(this._config?.icon_tap_action)
);
}
private _featurePosition = memoizeOne((config: TileCardConfig) => {
if (config.vertical) {
return "bottom";
}
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
protected render() {
if (!this._config || !this.hass) {
return nothing;
@@ -224,14 +250,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return html`
<ha-card>
<div class="content ${classMap(contentClasses)}">
<div class="icon-container">
<ha-tile-icon>
<ha-svg-icon .path=${mdiHelp}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-icon>
<ha-svg-icon slot="icon" .path=${mdiHelp}></ha-svg-icon>
<ha-tile-badge class="not-found">
<ha-svg-icon .path=${mdiExclamationThick}></ha-svg-icon>
</ha-tile-badge>
</div>
</ha-tile-icon>
<ha-tile-info
.primary=${entityId}
secondary=${this.hass.localize("ui.card.tile.not_found")}
@@ -266,6 +290,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
? this._getImageUrl(stateObj)
: undefined;
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const containerOrientationClass =
featurePosition === "inline" ? "horizontal" : "";
return html`
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
<div
@@ -275,58 +305,49 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
role=${ifDefined(this.hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasCardAction ? "0" : undefined)}
role=${ifDefined(this._hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasCardAction ? "0" : undefined)}
aria-labelledby="info"
>
<ha-ripple .disabled=${!this.hasCardAction}></ha-ripple>
<ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div>
<div class="container">
<div class="container ${containerOrientationClass}">
<div class="content ${classMap(contentClasses)}">
<div
class="icon-container"
role=${ifDefined(this.hasIconAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasIconAction ? "0" : undefined)}
<ha-tile-icon
role=${ifDefined(this._hasIconAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasIconAction ? "0" : undefined)}
@action=${this._handleIconAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.icon_hold_action),
hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
})}
.interactive=${this._hasIconAction}
.imageStyle=${DOMAIN_IMAGE_SHAPE[domain]}
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
>
${imageUrl
? html`
<ha-tile-image
.imageStyle=${DOMAIN_IMAGE_STYLE[domain] || "circle"}
.imageUrl=${imageUrl}
></ha-tile-image>
`
: html`
<ha-tile-icon
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
>
<ha-state-icon
.icon=${this._config.icon}
.stateObj=${stateObj}
.hass=${this.hass}
></ha-state-icon>
</ha-tile-icon>
`}
<ha-state-icon
slot="icon"
.icon=${this._config.icon}
.stateObj=${stateObj}
.hass=${this.hass}
></ha-state-icon>
${renderTileBadge(stateObj, this.hass)}
</div>
</ha-tile-icon>
<ha-tile-info
id="info"
.primary=${name}
.secondary=${stateDisplay}
></ha-tile-info>
</div>
${this._config.features
${features.length > 0
? html`
<hui-card-features
.hass=${this.hass}
.stateObj=${stateObj}
.color=${this._config.color}
.features=${this._config.features}
.features=${features}
></hui-card-features>
`
: nothing}
@@ -363,6 +384,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
}
[role="button"]:focus {
outline: none;
@@ -383,6 +405,10 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
flex-direction: column;
flex: 1;
}
.container.horizontal {
flex-direction: row;
}
.content {
position: relative;
display: flex;
@@ -392,53 +418,35 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
flex: 1;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.container.horizontal .content {
width: 50%;
}
.vertical {
flex-direction: column;
text-align: center;
justify-content: center;
}
.vertical .icon-container {
margin-bottom: 10px;
margin-right: 0;
margin-inline-start: initial;
margin-inline-end: initial;
}
.vertical ha-tile-info {
width: 100%;
flex: none;
}
.icon-container {
position: relative;
flex: none;
margin-right: 10px;
margin-inline-start: initial;
margin-inline-end: 10px;
direction: var(--direction);
transition: transform 180ms ease-in-out;
}
.icon-container ha-tile-icon,
.icon-container ha-tile-image {
ha-tile-icon {
--tile-icon-color: var(--tile-color);
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
position: relative;
padding: 6px;
margin: -6px;
}
.icon-container ha-tile-badge {
ha-tile-badge {
position: absolute;
top: -3px;
right: -3px;
inset-inline-end: -3px;
top: 3px;
right: 3px;
inset-inline-end: 3px;
inset-inline-start: initial;
}
.icon-container[role="button"] {
pointer-events: auto;
}
.icon-container[role="button"]:focus-visible,
.icon-container[role="button"]:active {
transform: scale(1.2);
}
ha-tile-info {
position: relative;
min-width: 0;
@@ -447,6 +455,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
hui-card-features {
--feature-color: var(--tile-color);
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: 50%;
--feature-height: 36px;
padding: 10px;
padding-inline-start: 0;
}
ha-tile-icon[data-domain="alarm_control_panel"][data-state="pending"],

View File

@@ -37,6 +37,7 @@ import type {
LovelaceGridOptions,
} from "../types";
import type { WeatherForecastCardConfig } from "./types";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@customElement("hui-weather-forecast-card")
class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
@@ -106,7 +107,9 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
!this.isConnected ||
!this.hass ||
!this._config ||
!this._needForecastSubscription()
!this._needForecastSubscription() ||
!isComponentLoaded(this.hass, "weather") ||
!this.hass.states[this._config!.entity]
) {
return;
}
@@ -118,7 +121,14 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
(event) => {
this._forecastEvent = event;
}
);
).catch((e) => {
if (e.code === "invalid_entity_id") {
setTimeout(() => {
this._subscribed = undefined;
}, 2000);
}
throw e;
});
}
public connectedCallback(): void {

View File

@@ -533,6 +533,7 @@ export interface TileCardConfig extends LovelaceCardConfig {
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
features?: LovelaceCardFeatureConfig[];
features_position?: "bottom" | "inline";
}
export interface HeadingCardConfig extends LovelaceCardConfig {

View File

@@ -18,6 +18,7 @@ import {
startOfWeek,
startOfYear,
subDays,
subMonths,
} from "date-fns";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -179,6 +180,30 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
calcDate(today, startOfYear, this.hass.locale, this.hass.config),
calcDate(today, endOfYear, this.hass.locale, this.hass.config),
],
[this.hass.localize("ui.components.date-range-picker.ranges.now-7d")]: [
calcDate(today, subDays, this.hass.locale, this.hass.config, 7),
calcDate(today, subDays, this.hass.locale, this.hass.config, 1),
],
[this.hass.localize("ui.components.date-range-picker.ranges.now-30d")]:
[
calcDate(today, subDays, this.hass.locale, this.hass.config, 30),
calcDate(today, subDays, this.hass.locale, this.hass.config, 1),
],
[this.hass.localize("ui.components.date-range-picker.ranges.now-12m")]:
[
calcDate(
subMonths(today, 12),
startOfMonth,
this.hass.locale,
this.hass.config
),
calcDate(
subMonths(today, 1),
endOfMonth,
this.hass.locale,
this.hass.config
),
],
};
}
}
@@ -248,6 +273,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.ranges=${this._ranges}
@value-changed=${this._dateRangeChanged}
minimal
header-position
></ha-date-range-picker>
</div>

View File

@@ -200,8 +200,6 @@ export class HuiGenericEntityRow extends LitElement {
padding-inline-start: 16px;
padding-inline-end: 8px;
flex: 1 1 30%;
min-height: 40px;
align-content: center;
}
.info,
.info > * {
@@ -235,8 +233,6 @@ export class HuiGenericEntityRow extends LitElement {
}
.value {
direction: ltr;
min-height: 40px;
align-content: center;
}
`;
}

View File

@@ -93,6 +93,7 @@ export class HuiEntityBadgeEditor
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},

View File

@@ -8,6 +8,7 @@ import {
assert,
assign,
boolean,
enums,
object,
optional,
string,
@@ -15,6 +16,7 @@ import {
} from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-form/ha-form";
import type {
@@ -48,12 +50,25 @@ const cardConfigStruct = assign(
show_entity_picture: optional(boolean()),
vertical: optional(boolean()),
tap_action: optional(actionConfigStruct),
icon_tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
icon_tap_action: optional(actionConfigStruct),
icon_hold_action: optional(actionConfigStruct),
icon_double_tap_action: optional(actionConfigStruct),
features: optional(array(any())),
features_position: optional(enums(["bottom", "inline"])),
})
);
const ADVANCED_ACTIONS = [
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const;
type AdvancedActions = (typeof ADVANCED_ACTIONS)[number];
@customElement("hui-tile-card-editor")
export class HuiTileCardEditor
extends LitElement
@@ -63,13 +78,46 @@ export class HuiTileCardEditor
@state() private _config?: TileCardConfig;
@state() private _displayActions?: AdvancedActions[];
public setConfig(config: TileCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
if (this._displayActions) return;
this._setDisplayActions(config);
}
private _setDisplayActions(config: TileCardConfig) {
this._displayActions = ADVANCED_ACTIONS.filter(
(action) => action in config
);
}
private _resetConfiguredActions() {
this._displayActions = undefined;
}
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
this._setDisplayActions(this._config);
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._resetConfiguredActions();
}
private _schema = memoizeOne(
(entityId: string | undefined, hideState: boolean) =>
(
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
vertical: boolean,
displayActions: AdvancedActions[] = []
) =>
[
{ name: "entity", selector: { entity: {} } },
{
@@ -105,12 +153,6 @@ export class HuiTileCardEditor
boolean: {},
},
},
{
name: "vertical",
selector: {
boolean: {},
},
},
{
name: "hide_state",
selector: {
@@ -132,6 +174,43 @@ export class HuiTileCardEditor
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "",
type: "grid",
schema: [
{
name: "content_layout",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
})),
},
},
},
{
name: "features_position",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["bottom", "inline"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.features_position_options.${value}`
),
value,
disabled: vertical && value === "inline",
})),
},
},
},
],
},
],
},
{
@@ -158,14 +237,14 @@ export class HuiTileCardEditor
},
},
},
{
name: "hold_action",
...displayActions.map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none",
default_action: "none" as const,
},
},
},
})),
],
},
] as const satisfies readonly HaFormSchema[]
@@ -179,9 +258,23 @@ export class HuiTileCardEditor
const entityId = this._config!.entity;
const stateObj = entityId ? this.hass!.states[entityId] : undefined;
const schema = this._schema(entityId, this._config!.hide_state ?? false);
const schema = this._schema(
this.hass.localize,
entityId,
this._config.hide_state ?? false,
this._config.vertical ?? false,
this._displayActions
);
const data = this._config;
const data = {
...this._config,
content_layout: this._config.vertical ? "vertical" : "horizontal",
};
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || data.vertical) {
data.features_position = "bottom";
}
return html`
<ha-form
@@ -233,6 +326,12 @@ export class HuiTileCardEditor
delete config.state_content;
}
// Convert content_layout to vertical
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
fireEvent(this, "config-changed", { config });
}
@@ -287,12 +386,14 @@ export class HuiTileCardEditor
switch (schema.name) {
case "color":
case "icon_tap_action":
case "icon_hold_action":
case "icon_double_tap_action":
case "show_entity_picture":
case "vertical":
case "hide_state":
case "state_content":
case "content_layout":
case "appearance":
case "interactions":
case "features_position":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}`
);
@@ -328,6 +429,14 @@ export class HuiTileCardEditor
display: block;
margin-bottom: 24px;
}
.info {
color: var(--secondary-text-color);
margin-top: 0;
margin-bottom: 8px;
}
.features-form {
margin-bottom: 8px;
}
`,
];
}

View File

@@ -147,12 +147,13 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
if (!this.lovelace) return nothing;
const sections = this.sections;
const totalSectionCount =
this._sectionColumnCount + (this.lovelace?.editMode ? 1 : 0);
const editMode = this.lovelace.editMode;
const maxColumnCount = this._columnsController.value ?? 1;
const totalSectionCount = this._sectionColumnCount;
const showExtraColumn =
totalSectionCount < maxColumnCount && totalSectionCount > 0 && editMode;
return html`
<hui-view-badges
.hass=${this.hass}
@@ -174,6 +175,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="container ${classMap({
dense: Boolean(this._config?.dense_section_placement),
"extra-column": showExtraColumn,
})}"
style=${styleMap({
"--total-section-count": totalSectionCount,
@@ -250,13 +252,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.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}
@@ -456,6 +451,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.container {
--column-count: min(var(--max-column-count), var(--total-section-count));
--container-max-width: calc(
var(--column-count) * var(--column-max-width) +
(var(--column-count) - 1) * var(--column-gap)
);
display: grid;
align-items: start;
justify-content: center;
@@ -465,11 +464,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
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)
);
max-width: var(--container-max-width);
}
.container.extra-column {
grid-template-columns: repeat(var(--column-count), 1fr) 76px;
max-width: calc(var(--column-gap) + 76px + var(--container-max-width));
}
.container.dense {
grid-auto-flow: row dense;
}
@@ -483,38 +485,20 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
position: relative;
display: flex;
flex-direction: column;
margin-top: 36px;
margin-top: 34px;
}
.create-section-container .card {
display: none;
}
.create-section-container:has(.card) .drop-helper {
display: flex;
}
.create-section-container:has(.card) .create-section {
display: none;
}
.drop-helper {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
outline: none;
background: none;
cursor: pointer;
border-radius: var(--ha-card-border-radius, 12px);
border: 2px dashed var(--primary-color);
height: calc(var(--row-height) + 2 * (var(--row-gap) + 2px));
padding: 8px;
box-sizing: border-box;
width: 100%;
--ha-ripple-color: var(--primary-color);
--ha-ripple-hover-opacity: 0.04;
--ha-ripple-pressed-opacity: 0.12;
.create-section-container:has(.card) .create-section:after {
content: "";
position: absolute;
display: block;
inset: 0;
background: var(--primary-color);
opacity: 0.12;
}
.drop-helper p {
@@ -537,7 +521,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
height: calc(var(--row-height) + 2 * (var(--row-gap) + 2px));
padding: 8px;
box-sizing: border-box;
width: 100%;
width: 76px;
--ha-ripple-color: var(--primary-color);
--ha-ripple-hover-opacity: 0.04;
--ha-ripple-pressed-opacity: 0.12;

View File

@@ -1,14 +1,11 @@
import { mdiAlertCircleOutline } from "@mdi/js";
import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list-item";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-formfield";
import "../../components/ha-radio";
import "../../components/ha-button";
import "../../components/ha-circular-progress";
import "../../components/ha-svg-icon";
import "../../components/ha-list-item";
import type { HaRadio } from "../../components/ha-radio";
import "../../components/ha-select";
import "../../components/ha-settings-row";
@@ -17,10 +14,8 @@ import {
DEFAULT_ACCENT_COLOR,
DEFAULT_PRIMARY_COLOR,
} from "../../resources/styles-data";
import type { HomeAssistant, ThemeSettings } from "../../types";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import type { StorageLocation } from "../../state/themes-mixin";
import { subscribeSelectedTheme } from "../../data/ws-themes";
const USE_DEFAULT_THEME = "__USE_DEFAULT_THEME__";
const HOME_ASSISTANT_THEME = "default";
@@ -31,78 +26,57 @@ export class HaPickThemeRow extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false })
public storageLocation: StorageLocation = "browser";
@state() _themeNames: string[] = [];
@state() private _selectedTheme?: ThemeSettings;
@state() private _loading = false;
protected render(): TemplateResult {
if (this._loading) {
return html`<ha-circular-progress indeterminate></ha-circular-progress>`;
}
const hasThemes =
this.hass.themes.themes && Object.keys(this.hass.themes.themes).length;
const curThemeIsUseDefault = this._selectedTheme?.theme === "";
const curTheme = this._selectedTheme?.theme
? this._selectedTheme?.theme
: this._selectedTheme?.dark
const curThemeIsUseDefault = this.hass.selectedTheme?.theme === "";
const curTheme = this.hass.selectedTheme?.theme
? this.hass.selectedTheme?.theme
: this.hass.themes.darkMode
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const themeSettings = this.hass.selectedTheme;
return html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading"
>${this.hass.localize("ui.panel.profile.themes.header")}</span
>
<span
slot="description"
class=${this.storageLocation === "user" &&
this.hass.browserThemeEnabled
? "device-info"
: ""}
>
${!hasThemes &&
!(this.storageLocation === "user" && this.hass.browserThemeEnabled)
<span slot="description">
${!hasThemes
? this.hass.localize("ui.panel.profile.themes.error_no_theme")
: ""}
${this.storageLocation === "user" && this.hass.browserThemeEnabled
? html`<ha-svg-icon .path=${mdiAlertCircleOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.profile.themes.device.user_theme_info"
)}`
: html`<a
href=${documentationUrl(
this.hass,
"/integrations/frontend/#defining-themes"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>`}
<a
href=${documentationUrl(
this.hass,
"/integrations/frontend/#defining-themes"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>
</span>
<ha-select
.label=${this.hass.localize("ui.panel.profile.themes.dropdown_label")}
.disabled=${!hasThemes}
.value=${this._selectedTheme?.theme || USE_DEFAULT_THEME}
.value=${this.hass.selectedTheme?.theme || USE_DEFAULT_THEME}
@selected=${this._handleThemeSelection}
naturalMenuWidth
>
<ha-list-item .value=${USE_DEFAULT_THEME}>
<mwc-list-item .value=${USE_DEFAULT_THEME}>
${this.hass.localize("ui.panel.profile.themes.use_default")}
</ha-list-item>
<ha-list-item .value=${HOME_ASSISTANT_THEME}>
</mwc-list-item>
<mwc-list-item .value=${HOME_ASSISTANT_THEME}>
Home Assistant
</ha-list-item>
</mwc-list-item>
${this._themeNames.map(
(theme) => html`
<ha-list-item .value=${theme}>${theme}</ha-list-item>
<mwc-list-item .value=${theme}>${theme}</mwc-list-item>
`
)}
</ha-select>
@@ -112,7 +86,7 @@ export class HaPickThemeRow extends LitElement {
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
? html` <div class="inputs">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.profile.themes.dark_mode.auto"
@@ -122,7 +96,7 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="auto"
.checked=${this._selectedTheme?.dark === undefined}
.checked=${themeSettings?.dark === undefined}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -134,7 +108,7 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="light"
.checked=${this._selectedTheme?.dark === false}
.checked=${themeSettings?.dark === false}
>
</ha-radio>
</ha-formfield>
@@ -147,14 +121,14 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleDarkMode}
name="dark_mode"
value="dark"
.checked=${this._selectedTheme?.dark === true}
.checked=${themeSettings?.dark === true}
>
</ha-radio>
</ha-formfield>
${curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-textfield
.value=${this._selectedTheme?.primaryColor ||
.value=${themeSettings?.primaryColor ||
DEFAULT_PRIMARY_COLOR}
type="color"
.label=${this.hass.localize(
@@ -164,8 +138,7 @@ export class HaPickThemeRow extends LitElement {
@change=${this._handleColorChange}
></ha-textfield>
<ha-textfield
.value=${this._selectedTheme?.accentColor ||
DEFAULT_ACCENT_COLOR}
.value=${themeSettings?.accentColor || DEFAULT_ACCENT_COLOR}
type="color"
.label=${this.hass.localize(
"ui.panel.profile.themes.accent_color"
@@ -173,36 +146,19 @@ export class HaPickThemeRow extends LitElement {
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-textfield>
${this._selectedTheme?.primaryColor ||
this._selectedTheme?.accentColor
? html`<ha-button @click=${this._resetColors}>
${themeSettings?.primaryColor || themeSettings?.accentColor
? html` <mwc-button @click=${this._resetColors}>
${this.hass.localize("ui.panel.profile.themes.reset")}
</ha-button>`
: nothing}
</mwc-button>`
: ""}
</div>`
: nothing}
: ""}
</div>`
: nothing}
: ""}
`;
}
public willUpdate(changedProperties: PropertyValues) {
if (!this.hasUpdated) {
if (this.storageLocation === "browser") {
this._selectedTheme = this.hass.selectedTheme ?? undefined;
} else {
this._loading = true;
this._selectedTheme = undefined;
subscribeSelectedTheme(
this.hass,
(selectedTheme?: ThemeSettings | null) => {
this._selectedTheme = selectedTheme ?? undefined;
this._loading = false;
}
);
}
}
const oldHass = changedProperties.get("hass") as undefined | HomeAssistant;
const themesChanged =
changedProperties.has("hass") &&
@@ -213,34 +169,13 @@ export class HaPickThemeRow extends LitElement {
}
}
private _shouldSaveHass() {
return (
this.storageLocation === "browser" ||
(this.storageLocation === "user" && !this.hass.browserThemeEnabled)
);
}
private _updateSelectedTheme(updatedTheme: Partial<ThemeSettings>) {
this._selectedTheme = {
...this._selectedTheme,
...updatedTheme,
theme: updatedTheme.theme ?? this._selectedTheme?.theme ?? "",
};
fireEvent(this, "settheme", {
settings: this._selectedTheme,
storageLocation: this.storageLocation,
saveHass: this._shouldSaveHass(),
});
}
private _handleColorChange(ev: CustomEvent) {
const target = ev.target as any;
this._updateSelectedTheme({ [target.name]: target.value });
fireEvent(this, "settheme", { [target.name]: target.value });
}
private _resetColors() {
this._updateSelectedTheme({
fireEvent(this, "settheme", {
primaryColor: undefined,
accentColor: undefined,
});
@@ -263,18 +198,18 @@ export class HaPickThemeRow extends LitElement {
dark = true;
break;
}
this._updateSelectedTheme({ dark });
fireEvent(this, "settheme", { dark });
}
private _handleThemeSelection(ev) {
const theme = ev.target.value;
if (theme === this._selectedTheme?.theme) {
if (theme === this.hass.selectedTheme?.theme) {
return;
}
if (theme === USE_DEFAULT_THEME) {
if (this.hass.selectedTheme?.theme) {
this._updateSelectedTheme({
fireEvent(this, "settheme", {
theme: "",
primaryColor: undefined,
accentColor: undefined,
@@ -282,7 +217,7 @@ export class HaPickThemeRow extends LitElement {
}
return;
}
this._updateSelectedTheme({
fireEvent(this, "settheme", {
theme,
primaryColor: undefined,
accentColor: undefined,
@@ -314,12 +249,6 @@ export class HaPickThemeRow extends LitElement {
flex-grow: 1;
margin: 0 4px;
}
.device-info {
color: var(--warning-color);
display: flex;
align-items: center;
gap: 8px;
}
`;
}

View File

@@ -1,12 +1,10 @@
import "@material/mwc-button";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-card";
import "../../components/ha-button";
import "../../components/ha-expansion-panel";
import "../../components/ha-switch";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import { isExternal } from "../../data/external";
@@ -29,7 +27,6 @@ import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
import type { HaSwitch } from "../../components/ha-switch";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@@ -41,8 +38,6 @@ class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _browserThemeActivated = false;
private _unsubCoreData?: UnsubscribeFunc;
private _getCoreData() {
@@ -96,9 +91,9 @@ class HaProfileSectionGeneral extends LitElement {
: ""}
</div>
<div class="card-actions">
<ha-button class="warning" @click=${this._handleLogOut}>
<mwc-button class="warning" @click=${this._handleLogOut}>
${this.hass.localize("ui.panel.profile.logout")}
</ha-button>
</mwc-button>
</div>
</ha-card>
<ha-card
@@ -133,12 +128,6 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
this._browserThemeActivated}
.storageLocation=${"user"}
></ha-pick-theme-row>
${this.hass.user!.is_admin
? html`
<ha-advanced-mode-row
@@ -159,42 +148,10 @@ class HaProfileSectionGeneral extends LitElement {
<div class="card-content">
${this.hass.localize("ui.panel.profile.client_settings_detail")}
</div>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
isExternal
? "ui.panel.profile.themes.device.mobile_app_header"
: "ui.panel.profile.themes.device.browser_header"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.profile.themes.device.description"
)}
</span>
<ha-switch
.checked=${this.hass.browserThemeEnabled ||
this._browserThemeActivated}
@change=${this._toggleBrowserTheme}
></ha-switch>
</ha-settings-row>
${this.hass.browserThemeEnabled || this._browserThemeActivated
? html`
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.profile.themes.device.custom_theme"
)}
expanded
>
<ha-pick-theme-row
class="device-theme"
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
</ha-expansion-panel>
`
: nothing}
<ha-pick-theme-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
@@ -210,11 +167,11 @@ class HaProfileSectionGeneral extends LitElement {
"ui.panel.profile.customize_sidebar.description"
)}
</span>
<ha-button @click=${this._customizeSidebar}>
<mwc-button @click=${this._customizeSidebar}>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</mwc-button>
</ha-settings-row>
${this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
@@ -268,39 +225,6 @@ class HaProfileSectionGeneral extends LitElement {
});
}
private async _toggleBrowserTheme(ev: Event) {
const switchElement = ev.target as HaSwitch;
const enabled = switchElement.checked;
if (!enabled) {
if (!this.hass.browserThemeEnabled && this._browserThemeActivated) {
// no changed have made, disable without confirmation
this._browserThemeActivated = false;
} else {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.profile.themes.device.delete_header"
),
text: this.hass.localize(
"ui.panel.profile.themes.device.delete_description"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (confirm) {
this._browserThemeActivated = false;
fireEvent(this, "resetBrowserTheme");
} else {
// revert switch
switchElement.click();
}
}
} else {
this._browserThemeActivated = true;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -327,14 +251,6 @@ class HaProfileSectionGeneral extends LitElement {
text-align: center;
color: var(--secondary-text-color);
}
ha-expansion-panel {
margin: 0 8px 8px;
}
.device-theme {
display: block;
padding-bottom: 16px;
}
`,
];
}

View File

@@ -3,37 +3,18 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import type { HASSDomEvent } from "../common/dom/fire_event";
import {
fetchSelectedTheme,
saveSelectedTheme,
SELECTED_THEME_KEY,
subscribeSelectedTheme,
subscribeThemes,
} from "../data/ws-themes";
import type { Constructor, HomeAssistant, ThemeSettings } from "../types";
import { clearStateKey, storeState } from "../util/ha-pref-storage";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
export type StorageLocation = "user" | "browser";
interface SetThemeSettings {
settings: ThemeSettings;
storageLocation: StorageLocation;
saveHass: boolean;
}
declare global {
// for add event listener
interface HTMLElementEventMap {
settheme: HASSDomEvent<SetThemeSettings>;
settheme: HASSDomEvent<Partial<HomeAssistant["selectedTheme"]>>;
}
interface HASSDomEvents {
settheme: SetThemeSettings;
resetBrowserTheme: undefined;
}
interface FrontendUserData {
selectedTheme?: ThemeSettings;
settheme: Partial<HomeAssistant["selectedTheme"]>;
}
}
@@ -46,38 +27,16 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.addEventListener("settheme", (ev) => {
if (ev.detail.saveHass) {
this._updateHass({
selectedTheme: ev.detail.settings,
browserThemeEnabled: ev.detail.storageLocation === "browser",
});
this._animateApplyTheme(mql.matches);
}
if (ev.detail.storageLocation === "browser") {
storeState(this.hass!);
} else {
if (ev.detail.saveHass) {
clearStateKey(SELECTED_THEME_KEY);
}
saveSelectedTheme(this.hass!, ev.detail.settings);
}
});
this.addEventListener("resetBrowserTheme", async () => {
clearStateKey(SELECTED_THEME_KEY);
const selectedTheme = await fetchSelectedTheme(this.hass!);
this._updateHass({
selectedTheme,
browserThemeEnabled: false,
selectedTheme: {
...this.hass!.selectedTheme!,
...ev.detail,
},
});
this._animateApplyTheme(mql.matches);
this._applyTheme(mql.matches);
storeState(this.hass!);
});
mql.addEventListener("change", (ev) =>
this._animateApplyTheme(ev.matches)
);
mql.addListener((ev) => this._applyTheme(ev.matches));
if (!this._themeApplied && mql.matches) {
applyThemesOnElement(
document.documentElement,
@@ -104,62 +63,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
invalidateThemeCache();
this._applyTheme(mql.matches);
});
subscribeSelectedTheme(
this.hass!,
(selectedTheme?: ThemeSettings | null) => {
if (
!window.localStorage.getItem(SELECTED_THEME_KEY) &&
selectedTheme
) {
this._themeApplied = true;
this._updateHass({
selectedTheme,
browserThemeEnabled: false,
});
this._applyTheme(mql.matches);
}
}
);
}
private async _animateApplyTheme(darkPreferred: boolean) {
if (!this.hass) {
return;
}
if (!document.startViewTransition || !document.documentElement.animate) {
this._applyTheme(darkPreferred);
} else {
await document.startViewTransition(() => {
this._applyTheme(darkPreferred);
}).ready;
const { top, left, width, height } =
document.documentElement.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(
Math.max(left, right),
Math.max(top, bottom)
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "linear(0, 0.1, 1)",
pseudoElement: "::view-transition-new(root)",
}
);
}
}
private _applyTheme(darkPreferred: boolean) {

View File

@@ -228,7 +228,7 @@
"run": "[%key:ui::card::service::run%]",
"running_single": "Running…",
"running_queued": "{queued} queued",
"running_parallel": "{active} Running…",
"running_parallel": "{active} running…",
"cancel": "Cancel",
"cancel_multiple": "Cancel {number}",
"cancel_all": "Cancel all",
@@ -809,10 +809,16 @@
"ranges": {
"today": "Today",
"yesterday": "Yesterday",
"now-1h": "Last hour",
"now-12h": "Last 12 hours",
"now-24h": "Last 24 hours",
"this_week": "This week",
"this_quarter": "This quarter",
"this_month": "This month",
"this_year": "This year"
"now-7d": "Last 7 days",
"now-30d": "Last 30 days",
"this_year": "This year",
"now-12m": "Last 12 month"
}
},
"grid-size-picker": {
@@ -2463,7 +2469,7 @@
},
"retention": "Retention",
"custom_retention": "Custom retention",
"custom_retention_label": "Clean up every",
"custom_retention_label": "Keep only",
"retention_description": "Based on the maximum number of backups or how many days they should be kept.",
"retention_presets": {
"copies_3": "3 backups",
@@ -2506,6 +2512,7 @@
"menu": {
"upload_backup": "Upload backup"
},
"agent_error": "Error in location {name}",
"new_backup": "Backup now",
"onboarding": {
"title": "Set up backups",
@@ -2556,7 +2563,7 @@
"last_backup_failed_heading": "Last automatic backup failed",
"last_backup_failed_description": "The last automatic backup triggered {relative_time} wasn't successful.",
"last_backup_failed_locations_description": "The last automatic backup created {relative_time} wasn't stored in all locations.",
"last_successful_backup_description": "Last successful backup {relative_time} and stored in {count} {count, plural,\n one {location}\n other {locations}\n}.",
"last_successful_backup_description": "Last successful automatic backup {relative_time} and stored in {count} {count, plural,\n one {location}\n other {locations}\n}.",
"no_backup_heading": "No automatic backup available",
"no_backup_description": "You have no automatic backups yet.",
"backup_too_old_heading": "No backup for {count} {count, plural,\n one {day}\n other {days}\n}",
@@ -2644,7 +2651,9 @@
"title": "Locations",
"description": "Your backup will be stored on these locations when this default backup is created. You can use all locations for custom backups.",
"no_location": "No location selected",
"no_location_description": "You have to select at least one location to create a backup."
"no_location_description": "You have to select at least one location to create a backup.",
"more_locations": "Explore more locations",
"manage_network_storage": "Manage network storage"
},
"encryption_key": {
"title": "Encryption key",
@@ -6301,7 +6310,8 @@
"description": "Home Assistant is starting, please wait…"
},
"map": {
"reset_focus": "Reset focus"
"reset_focus": "Reset focus",
"toggle_grouping": "Toggle grouping"
},
"energy": {
"loading": "Loading…",
@@ -7111,12 +7121,22 @@
"color": "Color",
"color_helper": "Inactive state (e.g. off, closed) will not be colored.",
"icon_tap_action": "Icon tap behavior",
"interactions": "Interactions",
"icon_hold_action": "Icon hold behavior",
"icon_double_tap_action": "Icon double tap behavior",
"appearance": "Appearance",
"show_entity_picture": "Show entity picture",
"vertical": "Vertical",
"hide_state": "Hide state",
"state_content": "State content"
"state_content": "State content",
"features_position": "Features position",
"features_position_options": {
"bottom": "Bottom",
"inline": "Inline"
},
"content_layout": "Content layout",
"content_layout_options": {
"horizontal": "Horizontal",
"vertical": "Vertical"
}
},
"vertical-stack": {
"name": "Vertical stack",
@@ -7575,16 +7595,7 @@
"primary_color": "Primary color",
"accent_color": "Accent color",
"reset": "Reset",
"use_default": "Use default theme",
"device": {
"browser_header": "Browser theme",
"mobile_app_header": "Mobile app theme",
"custom_theme": "Custom theme",
"description": "Overwrite user theme with custom device settings",
"delete_header": "Delete device theme",
"delete_description": "Are you sure you want to delete the device specific theme?",
"user_theme_info": "Device theme is active. You won't see changes on user theme settings."
}
"use_default": "Use default theme"
},
"dashboard": {
"header": "Dashboard",

View File

@@ -223,7 +223,6 @@ export interface HomeAssistant {
config: HassConfig;
themes: Themes;
selectedTheme: ThemeSettings | null;
browserThemeEnabled?: boolean;
panels: Panels;
panelUrl: string;
// i18n

24
src/util/cache-manager.ts Normal file
View File

@@ -0,0 +1,24 @@
export class CacheManager<T> {
constructor(expiration?: number) {
this._expiration = expiration;
}
private _expiration?: number;
private _cache = new Map<string, T>();
public get(key: string): T | undefined {
return this._cache.get(key);
}
public set(key: string, value: T): void {
this._cache.set(key, value);
if (this._expiration) {
window.setTimeout(() => this._cache.delete(key), this._expiration);
}
}
public has(key: string): boolean {
return this._cache.has(key);
}
}

View File

@@ -1,9 +1,8 @@
import { SELECTED_THEME_KEY } from "../data/ws-themes";
import type { HomeAssistant } from "../types";
const STORED_STATE = [
"dockedSidebar",
SELECTED_THEME_KEY,
"selectedTheme",
"selectedLanguage",
"vibrate",
"debugConnection",
@@ -12,17 +11,9 @@ const STORED_STATE = [
"defaultPanel",
];
const CLEARABLE_STATE = [SELECTED_THEME_KEY];
export function storeState(hass: HomeAssistant) {
try {
const states = [...STORED_STATE];
if (!hass.browserThemeEnabled) {
states.splice(states.indexOf(SELECTED_THEME_KEY), 1);
}
states.forEach((key) => {
STORED_STATE.forEach((key) => {
const value = hass[key];
window.localStorage.setItem(
key,
@@ -41,18 +32,15 @@ export function storeState(hass: HomeAssistant) {
}
export function getState() {
const state: Partial<HomeAssistant> = {};
const state = {};
STORED_STATE.forEach((key) => {
const storageItem = window.localStorage.getItem(key);
if (storageItem !== null) {
let value = JSON.parse(storageItem);
// selectedTheme went from string to object on 20200718
if (key === SELECTED_THEME_KEY) {
if (typeof value === "string") {
value = { theme: value };
}
state.browserThemeEnabled = true;
if (key === "selectedTheme" && typeof value === "string") {
value = { theme: value };
}
// dockedSidebar went from boolean to enum on 20190720
if (key === "dockedSidebar" && typeof value === "boolean") {
@@ -67,9 +55,3 @@ export function getState() {
export function clearState() {
window.localStorage.clear();
}
export function clearStateKey(key: string) {
if (CLEARABLE_STATE.includes(key)) {
window.localStorage.removeItem(key);
}
}

View File

@@ -1,5 +0,0 @@
{
"rules": {
"import/no-extraneous-dependencies": 0
}
}

View File

@@ -51,4 +51,16 @@ describe("Color Conversion Tests", () => {
expect(theme2hex("#ff0000")).toBe("#ff0000");
expect(theme2hex("unicorn")).toBe("unicorn");
});
it("should convert rgb theme color to hex", () => {
expect(theme2hex("rgb( 255, 0, 0)")).toBe("#ff0000");
expect(theme2hex("rgb(0,255, 0)")).toBe("#00ff00");
expect(theme2hex("rgb(0, 0,255 )")).toBe("#0000ff");
});
it("should convert rgba theme color to hex by ignoring alpha", () => {
expect(theme2hex("rgba( 255, 0, 0, 0.5)")).toBe("#ff0000");
expect(theme2hex("rgba(0,255, 0, 0.3)")).toBe("#00ff00");
expect(theme2hex("rgba(0, 0,255 , 0.7)")).toBe("#0000ff");
});
});

View File

@@ -0,0 +1,45 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, it, expect } from "vitest";
import {
batteryIcon,
batteryLevelIcon,
} from "../../../src/common/entity/battery_icon";
describe("batteryIcon", () => {
it("should return correct icon for battery level", () => {
const stateObj: HassEntity = { state: "50" } as HassEntity;
expect(batteryIcon(stateObj)).toBe("mdi:battery-50");
});
it("should return correct icon for battery level with state", () => {
const stateObj: HassEntity = { state: "50" } as HassEntity;
expect(batteryIcon(stateObj, "20")).toBe("mdi:battery-20");
});
});
describe("batteryLevelIcon", () => {
it("should return correct icon for battery level", () => {
expect(batteryLevelIcon(50)).toBe("mdi:battery-50");
});
it("should return correct icon for charging battery", () => {
expect(batteryLevelIcon(50, true)).toBe("mdi:battery-charging-50");
});
it("should return charging outline icon for charging battery with 9%", () => {
expect(batteryLevelIcon(9, true)).toBe("mdi:battery-charging-outline");
});
it("should return alert icon for low battery", () => {
expect(batteryLevelIcon(5)).toBe("mdi:battery-alert-variant-outline");
});
it("should return unknown icon for invalid battery level", () => {
expect(batteryLevelIcon("invalid")).toBe("mdi:battery-unknown");
});
it("should return battery icon for on/off", () => {
expect(batteryLevelIcon("off")).toBe("mdi:battery");
expect(batteryLevelIcon("on")).toBe("mdi:battery-alert");
});
});

View File

@@ -1,6 +1,7 @@
import { assert, describe, it } from "vitest";
import { canToggleDomain } from "../../../src/common/entity/can_toggle_domain";
import type { HomeAssistant } from "../../../src/types";
describe("canToggleDomain", () => {
const hass: any = {
@@ -9,10 +10,6 @@ describe("canToggleDomain", () => {
turn_on: null, // Service keys only need to be present for test
turn_off: null,
},
lock: {
lock: null,
unlock: null,
},
sensor: {
custom_service: null,
},
@@ -23,10 +20,6 @@ describe("canToggleDomain", () => {
assert.isTrue(canToggleDomain(hass, "light"));
});
it("Detects locks toggle", () => {
assert.isTrue(canToggleDomain(hass, "lock"));
});
it("Detects sensors do not toggle", () => {
assert.isFalse(canToggleDomain(hass, "sensor"));
});
@@ -34,4 +27,58 @@ describe("canToggleDomain", () => {
it("Detects binary sensors do not toggle", () => {
assert.isFalse(canToggleDomain(hass, "binary_sensor"));
});
it("Detects covers toggle", () => {
assert.isTrue(
canToggleDomain(
{
services: {
cover: {
open_cover: null,
},
},
} as unknown as HomeAssistant,
"cover"
)
);
assert.isFalse(
canToggleDomain(
{
services: {
cover: {
open: null,
},
},
} as unknown as HomeAssistant,
"cover"
)
);
});
it("Detects lock toggle", () => {
assert.isTrue(
canToggleDomain(
{
services: {
lock: {
lock: null,
},
},
} as unknown as HomeAssistant,
"lock"
)
);
assert.isFalse(
canToggleDomain(
{
services: {
lock: {
unlock: null,
},
},
} as unknown as HomeAssistant,
"lock"
)
);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { batteryStateColorProperty } from "../../../../src/common/entity/color/battery_color";
describe("battery_color", () => {
it("should return green for high battery level", () => {
let color = batteryStateColorProperty("70");
expect(color).toBe("--state-sensor-battery-high-color");
color = batteryStateColorProperty("200");
expect(color).toBe("--state-sensor-battery-high-color");
});
it("should return yellow for medium battery level", () => {
let color = batteryStateColorProperty("69.99");
expect(color).toBe("--state-sensor-battery-medium-color");
color = batteryStateColorProperty("30");
expect(color).toBe("--state-sensor-battery-medium-color");
});
it("should return red for low battery level", () => {
let color = batteryStateColorProperty("29.999");
expect(color).toBe("--state-sensor-battery-low-color");
color = batteryStateColorProperty("-20");
expect(color).toBe("--state-sensor-battery-low-color");
});
// add nan test
it("should return undefined for non-numeric state", () => {
const color = batteryStateColorProperty("not a number");
expect(color).toBe(undefined);
});
});

View File

@@ -0,0 +1,56 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, it, expect } from "vitest";
import {
computeStateName,
computeStateNameFromEntityAttributes,
} from "../../../src/common/entity/compute_state_name";
describe("computeStateName", () => {
it("should return friendly_name if it exists", () => {
const stateObj = {
entity_id: "light.living_room",
attributes: { friendly_name: "Living Room Light" },
} as HassEntity;
expect(computeStateName(stateObj)).toBe("Living Room Light");
});
it("should return object id if friendly_name does not exist", () => {
const stateObj = {
entity_id: "light.living_room",
attributes: {},
} as HassEntity;
expect(computeStateName(stateObj)).toBe("living room");
});
});
describe("computeStateNameFromEntityAttributes", () => {
it("should return friendly_name if it exists", () => {
const entityId = "light.living_room";
const attributes = { friendly_name: "Living Room Light" };
expect(computeStateNameFromEntityAttributes(entityId, attributes)).toBe(
"Living Room Light"
);
});
it("should return friendly_name 0", () => {
const entityId = "light.living_room";
const attributes = { friendly_name: 0 };
expect(computeStateNameFromEntityAttributes(entityId, attributes)).toBe(
"0"
);
});
it("should return empty if friendly_name is null", () => {
const entityId = "light.living_room";
const attributes = { friendly_name: null };
expect(computeStateNameFromEntityAttributes(entityId, attributes)).toBe("");
});
it("should return object id if friendly_name does not exist", () => {
const entityId = "light.living_room";
const attributes = {};
expect(computeStateNameFromEntityAttributes(entityId, attributes)).toBe(
"living room"
);
});
});

View File

@@ -0,0 +1,54 @@
import type { HassEntity } from "home-assistant-js-websocket";
import {
mdiArrowCollapseHorizontal,
mdiArrowDown,
mdiArrowExpandHorizontal,
mdiArrowUp,
} from "@mdi/js";
import { describe, it, expect } from "vitest";
import {
computeOpenIcon,
computeCloseIcon,
} from "../../../src/common/entity/cover_icon";
describe("computeOpenIcon", () => {
it("returns mdiArrowExpandHorizontal for awning, door, gate, and curtain", () => {
const stateObj = { attributes: { device_class: "awning" } } as HassEntity;
expect(computeOpenIcon(stateObj)).toBe(mdiArrowExpandHorizontal);
stateObj.attributes.device_class = "door";
expect(computeOpenIcon(stateObj)).toBe(mdiArrowExpandHorizontal);
stateObj.attributes.device_class = "gate";
expect(computeOpenIcon(stateObj)).toBe(mdiArrowExpandHorizontal);
stateObj.attributes.device_class = "curtain";
expect(computeOpenIcon(stateObj)).toBe(mdiArrowExpandHorizontal);
});
it("returns mdiArrowUp for other device classes", () => {
const stateObj = { attributes: { device_class: "window" } } as HassEntity;
expect(computeOpenIcon(stateObj)).toBe(mdiArrowUp);
});
});
describe("computeCloseIcon", () => {
it("returns mdiArrowCollapseHorizontal for awning, door, gate, and curtain", () => {
const stateObj = { attributes: { device_class: "awning" } } as HassEntity;
expect(computeCloseIcon(stateObj)).toBe(mdiArrowCollapseHorizontal);
stateObj.attributes.device_class = "door";
expect(computeCloseIcon(stateObj)).toBe(mdiArrowCollapseHorizontal);
stateObj.attributes.device_class = "gate";
expect(computeCloseIcon(stateObj)).toBe(mdiArrowCollapseHorizontal);
stateObj.attributes.device_class = "curtain";
expect(computeCloseIcon(stateObj)).toBe(mdiArrowCollapseHorizontal);
});
it("returns mdiArrowDown for other device classes", () => {
const stateObj = { attributes: { device_class: "window" } } as HassEntity;
expect(computeCloseIcon(stateObj)).toBe(mdiArrowDown);
});
});

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, vi } from "vitest";
import {
isDeletableEntity,
deleteEntity,
} from "../../../src/common/entity/delete_entity";
import type { HomeAssistant } from "../../../src/types";
import type { EntityRegistryEntry } from "../../../src/data/entity_registry";
import type { IntegrationManifest } from "../../../src/data/integration";
import type { ConfigEntry } from "../../../src/data/config_entries";
import type { Helper } from "../../../src/panels/config/helpers/const";
describe("isDeletableEntity", () => {
it("should return true for restored entities", () => {
const hass = {
states: { "light.test": { attributes: { restored: true } } },
} as unknown as HomeAssistant;
const result = isDeletableEntity(hass, "light.test", [], [], [], []);
expect(result).toBe(true);
});
it("should return false for non-restored entities without config entry", () => {
const hass = {
states: { "light.test": { attributes: {} } },
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "light.test" },
] as EntityRegistryEntry[];
const result = isDeletableEntity(
hass,
"light.test",
[],
entityRegistry,
[],
[]
);
expect(result).toBe(false);
});
it("should return true for helper domain entities", () => {
const hass = {
states: { "input_boolean.test": { attributes: {} } },
config: { components: ["input_boolean"] },
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "input_boolean.test", unique_id: "123" },
] as EntityRegistryEntry[];
const fetchedHelpers = [{ id: "123" }] as Helper[];
const result = isDeletableEntity(
hass,
"input_boolean.test",
[],
entityRegistry,
[],
fetchedHelpers
);
expect(result).toBe(true);
});
it("should return false for non-helper domain entities without restored attribute", () => {
const hass = {
states: { "light.test": { attributes: {} } },
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "light.test" },
] as EntityRegistryEntry[];
const result = isDeletableEntity(
hass,
"light.test",
[],
entityRegistry,
[],
[]
);
expect(result).toBe(false);
});
it("should return true for entities with helper integration type", () => {
const hass = {
states: { "light.test": { attributes: {} } },
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "light.test", config_entry_id: "config_1" },
] as EntityRegistryEntry[];
const configEntries = [
{ entry_id: "config_1", domain: "light" },
] as ConfigEntry[];
const manifests = [
{ domain: "light", integration_type: "helper" },
] as IntegrationManifest[];
const result = isDeletableEntity(
hass,
"light.test",
manifests,
entityRegistry,
configEntries,
[]
);
expect(result).toBe(true);
});
});
describe("deleteEntity", () => {
it("should call removeEntityRegistryEntry for restored entities", () => {
const removeEntityRegistryEntry = vi.fn();
const hass = {
states: { "light.test": { attributes: { restored: true } } },
callWS: removeEntityRegistryEntry,
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "light.test" },
] as EntityRegistryEntry[];
deleteEntity(hass, "light.test", [], entityRegistry, [], []);
expect(removeEntityRegistryEntry).toHaveBeenCalledWith({
type: "config/entity_registry/remove",
entity_id: "light.test",
});
});
it("should call deleteConfigEntry for entities with helper integration type", () => {
const deleteConfigEntry = vi.fn();
const hass = {
states: { "light.test": { attributes: {} } },
callApi: deleteConfigEntry,
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "light.test", config_entry_id: "config_1" },
] as EntityRegistryEntry[];
const configEntries = [
{ entry_id: "config_1", domain: "light" },
] as ConfigEntry[];
const manifests = [
{ domain: "light", integration_type: "helper" },
] as IntegrationManifest[];
deleteEntity(
hass,
"light.test",
manifests,
entityRegistry,
configEntries,
[]
);
expect(deleteConfigEntry).toHaveBeenCalledOnce();
});
it("should call HELPERS_CRUD.delete for helper domain entities", () => {
const deleteCall = vi.fn();
const hass = {
states: { "input_boolean.test": { attributes: {} } },
config: { components: ["input_boolean"] },
callWS: deleteCall,
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "input_boolean.test", unique_id: "123" },
] as EntityRegistryEntry[];
const fetchedHelpers = [{ id: "123" }] as Helper[];
deleteEntity(
hass,
"input_boolean.test",
[],
entityRegistry,
[],
fetchedHelpers
);
expect(deleteCall).toHaveBeenCalledWith({
type: "input_boolean/delete",
input_boolean_id: "123",
});
});
it("should call removeEntityRegistryEntry for helper domain entities", () => {
const removeEntityRegistryEntry = vi.fn();
const hass = {
states: { "input_boolean.test": { attributes: { restored: true } } },
config: { components: ["input_boolean"] },
callWS: removeEntityRegistryEntry,
} as unknown as HomeAssistant;
const entityRegistry = [
{ entity_id: "input_boolean.test", unique_id: "124" },
] as EntityRegistryEntry[];
const fetchedHelpers = [{ id: "123" }] as Helper[];
deleteEntity(
hass,
"input_boolean.test",
[],
entityRegistry,
[],
fetchedHelpers
);
expect(removeEntityRegistryEntry).toHaveBeenCalledWith({
type: "config/entity_registry/remove",
entity_id: "input_boolean.test",
});
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CacheManager } from "../../src/util/cache-manager";
const savedSetTimeout = setTimeout;
describe("cache-manager", () => {
beforeEach(() => {
vi.useFakeTimers();
window.setTimeout = setTimeout;
});
afterEach(() => {
vi.useRealTimers();
window.setTimeout = savedSetTimeout;
});
it("should return value before expiration", async () => {
const cacheManager = new CacheManager<string>(1000);
cacheManager.set("key", "value");
expect(cacheManager.has("key")).toBe(true);
expect(cacheManager.get("key")).toBe("value");
vi.advanceTimersByTime(500);
expect(cacheManager.has("key")).toBe(true);
expect(cacheManager.get("key")).toBe("value");
});
it("should not return value after expiration", async () => {
const cacheManager = new CacheManager<string>(1000);
cacheManager.set("key", "value");
expect(cacheManager.has("key")).toBe(true);
expect(cacheManager.get("key")).toBe("value");
vi.advanceTimersByTime(2000);
expect(cacheManager.has("key")).toBe(false);
expect(cacheManager.get("key")).toBe(undefined);
});
it("should always return value if no expiration", async () => {
const cacheManager = new CacheManager<string>();
cacheManager.set("key", "value");
expect(cacheManager.has("key")).toBe(true);
expect(cacheManager.get("key")).toBe("value");
vi.advanceTimersByTime(10000000000000000000000);
expect(cacheManager.has("key")).toBe(true);
expect(cacheManager.get("key")).toBe("value");
});
});

View File

@@ -6,7 +6,6 @@ describe("ha-pref-storage", () => {
const mockHass = {
dockedSidebar: "auto",
selectedTheme: { theme: "default" },
vibrate: "false",
unknownKey: "unknownValue",
};
@@ -25,11 +24,15 @@ describe("ha-pref-storage", () => {
window.localStorage.setItem = vi.fn();
storeState(mockHass as unknown as HomeAssistant);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(7);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(8);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"dockedSidebar",
JSON.stringify("auto")
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedTheme",
JSON.stringify({ theme: "default" })
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedLanguage",
JSON.stringify(null)
@@ -38,19 +41,13 @@ describe("ha-pref-storage", () => {
"unknownKey",
JSON.stringify("unknownValue")
);
// browserThemeEnabled is not set in mockHass, so selectedTheme should not be stored
expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
"selectedTheme",
JSON.stringify({ theme: "default" })
);
});
test("storeState fails", async () => {
const { storeState } = await import("../../src/util/ha-pref-storage");
window.localStorage.setItem = vi.fn((key) => {
if (key === "selectedLanguage") {
if (key === "selectedTheme") {
throw new Error("Test error");
}
});
@@ -68,12 +65,12 @@ describe("ha-pref-storage", () => {
JSON.stringify("auto")
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"selectedLanguage",
JSON.stringify(null)
"selectedTheme",
JSON.stringify({ theme: "default" })
);
expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
"vibrate",
JSON.stringify("false")
"selectedLanguage",
JSON.stringify(null)
);
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledOnce();
@@ -90,7 +87,7 @@ describe("ha-pref-storage", () => {
window.localStorage.setItem("selectedTheme", JSON.stringify("test"));
window.localStorage.setItem("dockedSidebar", JSON.stringify(true));
window.localStorage.setItem("selectedLanguage", JSON.stringify("de"));
window.localStorage.setItem("selectedLanguage", JSON.stringify("german"));
// should not be in state
window.localStorage.setItem("testEntry", JSON.stringify("this is a test"));
@@ -99,21 +96,7 @@ describe("ha-pref-storage", () => {
expect(state).toEqual({
dockedSidebar: "docked",
selectedTheme: { theme: "test" },
browserThemeEnabled: true,
selectedLanguage: "de",
});
});
test("getState without theme", async () => {
const { getState } = await import("../../src/util/ha-pref-storage");
window.localStorage.setItem("dockedSidebar", JSON.stringify(true));
window.localStorage.setItem("selectedLanguage", JSON.stringify("de"));
const state = getState();
expect(state).toEqual({
dockedSidebar: "docked",
selectedLanguage: "de",
selectedLanguage: "german",
});
});

1043
yarn.lock

File diff suppressed because it is too large Load Diff