Compare commits

...
Author SHA1 Message Date
Paulus Schoutsen 9ecb41accd App info page no longer links to itself 2026-03-29 10:45:00 -04:00
dependabot[bot] 68cbaf6481 Bump node-forge from 1.3.3 to 1.4.0 (#30398)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 13:26:07 +01:00
renovate[bot] b9b249a317 Update dependency @codemirror/language to v6.12.3 (#30396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 12:04:55 +01:00
Petar Petrov df6578dfdf Refactor climate HVAC mode handling to be data-driven (#30356)
Refactor climate HVAC mode handling in state-history-chart-line to be data-driven

Replace repetitive per-mode detection, dataset creation, and series population
blocks with a single CLIMATE_MODE_CONFIGS array and loops over active modes.
2026-03-28 10:15:45 +01:00
ildar170975 b4a07f504c Map card editor: use context in attribute selector (#30393)
use context in attribute selector
2026-03-28 10:13:44 +01:00
Norbert Rittel 24b8078a9c Clarify multiple choice of Chart types in Device energy graph card (#30389) 2026-03-28 09:31:19 +02:00
Paul Bottein 2bd8a657b8 Revert entity naming change (#30384) 2026-03-27 21:45:54 +01:00
Wendelin 61724a52ba Fix picker field disabled background (#30385) 2026-03-27 16:32:49 +01:00
Aidan Timson 67d73261a4 Fix floating ha-toast (#30344) 2026-03-27 14:06:47 +01:00
Marcin BauerandWendelin 419ba64bed Use dedicated tab copy in automation add dialogs (#30378)
Co-authored-by: Wendelin <[email protected]>
2026-03-27 12:46:11 +00:00
Norbert Rittel e87155bba3 Drop "entities" from "Group by floor / area" in Sankey diagrams (#30382) 2026-03-27 14:16:46 +02:00
Petar Petrov d8a3939e22 Fix negative currency display on sensor card (#30359) 2026-03-27 12:29:58 +01:00
Paul Bottein 5141b6aea9 Revert "Show device name prefix in entity name field" (#30381)
Revert "Show device name prefix in entity name field (#30358)"

This reverts commit e1ac7e98b0.
2026-03-27 12:20:00 +01:00
Wendelin 9aaf1bdd07 Automation add TCA: Fix classMap usage (#30380) 2026-03-27 12:14:51 +01:00
Stefan AgnerandClaude Opus 4.6 a0e7c8e9bb Use goBack with fallback for app uninstall navigation (#30377)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 08:31:19 +00:00
Bram Kragten 8f482776b9 Numeric threshold selector fixes (#30350)
* Update numeric threshold

* Update ha-selector-numeric-threshold.ts
2026-03-27 09:50:21 +02:00
Norbert Rittel 2c80183ded Shorten collection_key_description to fit available space (#30376) 2026-03-27 09:41:58 +02:00
Paul Bottein 9bd5e25ee3 Only display entity name instead of friendly name in state info (#30365) 2026-03-27 09:41:11 +02:00
dependabot[bot] 994193465f Bump picomatch from 2.3.1 to 2.3.2 (#30375)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:37:15 +02:00
renovate[bot] 530b7ed457 Update dependency @rspack/core to v1.7.10 (#30374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:55 +02:00
renovate[bot] 24332e8288 Update vitest monorepo to v4.1.1 (#30357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:23 +02:00
renovate[bot] 980aaa2bac Update dependency typescript-eslint to v8.57.2 (#30367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:47 +02:00
renovate[bot] 344856cbc4 Update dependency tar to v7.5.13 (#30371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:19 +02:00
Bram Kragten 06877cbaaa Replace softprops/action-gh-release with gh release upload (#30362)
`replace softprops/action-gh-release` with `gh release upload`
2026-03-26 17:39:38 +01:00
Paul Bottein e1ac7e98b0 Show device name prefix in entity name field (#30358) 2026-03-26 17:13:24 +01:00
Paul Bottein 4b2426ab77 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:05:05 +01:00
Paul Bottein f9471d6b4c Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:01:26 +01:00
Bram Kragten 0645484258 Tighten action permissions (#30361) 2026-03-26 16:45:34 +01:00
Bram Kragten 2da3efb812 Add target error badge if target is missing (#30352)
* Add target error badge if target is missing

* Don't show for newly added items
2026-03-26 16:58:22 +02:00
Bram Kragten e3674e550f Replace ua-parser-js with simple regexs (#30355) 2026-03-26 14:48:05 +00:00
karwosts cff038fea4 Stabilize map colors (#30354) 2026-03-26 14:47:57 +00:00
Paulus Schoutsen bed39fd8e9 Hide features layout option until feature added (#30353) 2026-03-26 14:39:00 +00:00
arcsur b8346d0286 Support additional climate hvac_mode in state-history-chart-line (#30310)
* Support additional climate hvac_mode in state-history-chart-line

* Reverted changes to the chart legend to keep change focussed on the additonal hvac_mode support

* Remove the tests since they don't add value.
2026-03-26 14:36:08 +00:00
Petar Petrov 5f80b74322 Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 16:29:16 +02:00
Paul Bottein 73e0f1e18d Use hardcoded label for temperature and humidity sensor in climate dashboard (#30348)
* Only use entity name for climate view sensors

* Use hardcoded text
2026-03-26 15:06:25 +01:00
Paul Bottein 0b16b735e8 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 13:16:57 +01:00
Darren Griffin c88dcf10b9 Update Figma DesignKit link (#30342) 2026-03-26 09:47:31 +00:00
Timothy 08bc23e2e7 Set tap highlight color to transparent for button (#30340) 2026-03-26 08:55:27 +00:00
pefia 97d49f06b1 Fix number formatting fallback when Number.isNaN is unavailable (#30306)
Remove unnecessary Number.isNaN polyfill from number formatting
2026-03-26 10:33:29 +02:00
WendelinandPaul Bottein 50b727393d Add all hardware table (#30312)
* add all hardware table

* copilot review

* Updated tab names

* update localize keys

* Fix translations

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-03-26 09:10:09 +02:00
sir-Unknown 5c6dd2a697 Fix calendar event description not preserving line breaks (#30329)
Add `white-space: pre-line` to the event description style so that
newlines in the calendar event description are rendered correctly
instead of being collapsed into a single line.
2026-03-26 07:01:02 +01:00
109 changed files with 2272 additions and 2083 deletions
+3
View File
@@ -5,6 +5,9 @@ updates:
schedule:
interval: weekly
time: "06:00"
cooldown:
default-days-before-reopen: 30
default-days: 7
open-pull-requests-limit: 10
labels:
- Dependencies
+5
View File
@@ -8,6 +8,9 @@ on:
branches:
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -24,6 +27,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -59,6 +63,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+9
View File
@@ -18,6 +18,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint and check format
@@ -25,6 +28,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -59,6 +64,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -77,6 +84,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
+5
View File
@@ -7,6 +7,10 @@ on:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -28,6 +32,7 @@ jobs:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
+5
View File
@@ -9,6 +9,9 @@ on:
- dev
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -25,6 +28,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -60,6 +64,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+5
View File
@@ -5,6 +5,9 @@ on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -17,6 +20,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+5
View File
@@ -10,6 +10,9 @@ on:
branches:
- dev
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -22,6 +25,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
jobs:
triage:
+4
View File
@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
+2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+23 -9
View File
@@ -1,25 +1,39 @@
name: RelativeCI
on:
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
workflow_run:
workflows: [CI]
types:
- completed
permissions:
contents: read
actions: read
jobs:
upload:
name: Upload stats
upload-frontend-modern:
name: Upload stats (frontend/modern)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
strategy:
matrix:
bundle: [frontend]
build: [modern, legacy]
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-modern.json
upload-frontend-legacy:
name: Upload stats (frontend/legacy)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-legacy.json
+20 -14
View File
@@ -27,6 +27,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -34,13 +36,12 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
@@ -62,11 +63,10 @@ jobs:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: |
dist/*.whl
dist/*.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
wheels-init:
name: Init wheels build
@@ -74,15 +74,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Generate requirements.txt
env:
GITHUB_REF: ${{ github.ref }}
run: |
# Sleep to give pypi time to populate the new version across mirrors
sleep 240
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/[email protected]
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -99,11 +101,12 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
- name: Download Translations
@@ -113,8 +116,11 @@ jobs:
- name: Build landing-page
run: landing-page/script/build_landing_page
- name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
- name: Upload release asset
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
+4
View File
@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
+5
View File
@@ -8,6 +8,9 @@ on:
paths:
- src/translations/en.json
permissions:
contents: read
jobs:
upload:
name: Upload
@@ -15,6 +18,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Upload Translations
run: |
+1 -1
View File
@@ -19,7 +19,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
## Developers
+6 -8
View File
@@ -30,7 +30,7 @@
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/language": "6.12.2",
"@codemirror/language": "6.12.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
@@ -129,7 +129,6 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
@@ -151,7 +150,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.5",
"@rspack/core": "1.7.9",
"@rspack/core": "1.7.10",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -169,9 +168,8 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.0",
"@vitest/coverage-v8": "4.1.1",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -206,13 +204,13 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"tar": "7.5.12",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.0",
"vitest": "4.1.1",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
+40 -10
View File
@@ -4,11 +4,14 @@ import type {
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
@@ -18,22 +21,49 @@ export const computeEntityName = (
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry);
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
if (entry.name != null) {
return entry.name;
const name =
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}
if ("original_name" in entry && entry.original_name != null) {
return String(entry.original_name);
const deviceName = computeDeviceName(device);
// If the device name is the same as the entity name, consider empty entity name
if (deviceName === name) {
return undefined;
}
return undefined;
// Remove the device name from the entity name if it starts with it
if (deviceName && name) {
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
};
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
): boolean => !computeEntityName(stateObj, entities);
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);
@@ -5,6 +5,7 @@ import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -29,14 +30,23 @@ export interface EntityNameOptions {
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[] | undefined,
name: string | EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
@@ -45,7 +55,7 @@ export const computeEntityNameDisplay = (
return items.map((item) => item.text).join(separator);
}
const useDeviceName = entityUseDeviceName(stateObj, entities);
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
@@ -91,7 +101,7 @@ export const computeEntityNameList = (
const names = name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities);
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "area":
+3 -2
View File
@@ -142,9 +142,10 @@ const computeStateToPartsFromEntityAttributes = (
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
minusSign: "value",
};
const valueParts: ValuePart[] = [];
@@ -153,7 +154,7 @@ const computeStateToPartsFromEntityAttributes = (
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -0,0 +1,11 @@
/**
* Indicates whether the current browser supports the Popover API.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
*/
export const popoverSupported = globalThis?.HTMLElement?.prototype
? Object.prototype.hasOwnProperty.call(
globalThis.HTMLElement.prototype,
"popover"
)
: false;
-7
View File
@@ -71,13 +71,6 @@ export const formatNumberToParts = (
? numberFormatToLocale(localeOptions)
: undefined;
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
function isNaN(input) {
return typeof input === "number" && isNaN(input);
};
if (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
@@ -28,6 +28,13 @@ const safeParseFloat = (value) => {
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -429,23 +436,18 @@ export class StateHistoryChartLine extends LitElement {
(entityState) => entityState.attributes?.hvac_action
);
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
@@ -466,33 +468,19 @@ export class StateHistoryChartLine extends LitElement {
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
if (hasHeat) {
for (const { action, cssVar } of activeModes) {
addDataSet(
states.entity_id + "-heating",
`${states.entity_id}-${action}`,
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue("--state-climate-heat-color"),
computedStyles.getPropertyValue(cssVar),
true
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addDataSet(
states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
@@ -540,11 +528,8 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.current_temperature
);
const series = [curTemp];
if (hasHeat) {
series.push(isHeating(entityState) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(entityState) ? curTemp : null);
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
+322 -220
View File
@@ -1,15 +1,13 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
@@ -17,12 +15,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
@@ -73,10 +73,291 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _mode?: "composed" | "custom";
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (this._mode === undefined) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected render() {
const modeButtons = [
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_composed"
),
value: "composed",
},
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_custom"
),
value: "custom",
},
];
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="small"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
</div>
<div class="content">
${this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()}
</div>
</div>
${this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing}
`;
}
private _renderTextInput() {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
return html`
<ha-input
.disabled=${this.disabled}
.required=${this.required}
.value=${value}
@input=${this._textInputChanged}
></ha-input>
`;
}
private _renderPicker() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
`;
}
private _modeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._mode = ev.detail.value as "composed" | "custom";
}
private _textInputChanged(ev: Event) {
const value = (ev.target as HTMLInputElement).value;
const newValue: EntityNameItem[] = value
? [{ type: "text", text: value }]
: [];
this._setValue(newValue);
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
private _validTypes = memoizeOne((entityId?: string) => {
const options = new Set<string>(["text"]);
if (!entityId) {
@@ -161,157 +442,6 @@ export class HaEntityNamePicker extends LitElement {
})
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
protected render() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return "";
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this._items[this._editIndex];
@@ -362,58 +492,6 @@ export class HaEntityNamePicker extends LitElement {
return filteredItems;
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
@@ -421,13 +499,42 @@ export class HaEntityNamePicker extends LitElement {
}
.container {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
label {
display: block;
font-weight: 500;
}
.content {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
ha-generic-picker,
ha-input {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
.field:after {
display: block;
content: "";
position: absolute;
@@ -445,30 +552,25 @@ export class HaEntityNamePicker extends LitElement {
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
ha-chip-set {
padding: var(--ha-space-3);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
+2 -3
View File
@@ -1,11 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "./state-badge";
import "../ha-tooltip";
import "./state-badge";
@customElement("state-info")
class StateInfo extends LitElement {
@@ -22,7 +21,7 @@ class StateInfo extends LitElement {
return nothing;
}
const name = computeStateName(this.stateObj);
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
+4 -1
View File
@@ -56,7 +56,10 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts.find((part) => part.type === "value")?.value;
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
}
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);
+1
View File
@@ -57,6 +57,7 @@ export class HaButton extends Button {
.button {
font-size: var(--ha-font-size-m);
line-height: 1;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--ha-animation-duration-fast)
ease-out;
@@ -142,6 +142,19 @@ export const computeInitialHaFormData = (
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
+2 -7
View File
@@ -121,6 +121,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
css`
ha-combo-box-item[disabled] {
background-color: var(--ha-color-form-background-disabled);
--md-list-item-disabled-opacity: 0.5;
opacity: 0.5;
cursor: not-allowed;
}
ha-combo-box-item {
@@ -141,13 +143,6 @@ export class HaPickerField extends PickerMixin(LitElement) {
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item[disabled]:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
ha-combo-box-item:after {
display: block;
content: "";
@@ -2,6 +2,7 @@ import memoizeOne from "memoize-one";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiChartBellCurveCumulative } from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event";
import type {
NumericThresholdSelector,
@@ -76,6 +77,18 @@ export class HaNumericThresholdSelector extends LitElement {
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
(changedProperties.has("value") || changedProperties.has("selector")) &&
!this.value
) {
const mode = this._getMode();
const type = DEFAULT_TYPE[mode];
fireEvent(this, "value-changed", { value: { type } });
}
}
private _getUnitOptions() {
return this.selector.numeric_threshold?.unit_of_measurement;
}
@@ -220,6 +233,7 @@ export class HaNumericThresholdSelector extends LitElement {
return [
{
value: "any",
iconPath: mdiChartBellCurveCumulative,
label: localize(
"ui.components.selectors.numeric_threshold.changed.any"
),
@@ -481,7 +495,7 @@ export class HaNumericThresholdSelector extends LitElement {
.value-inputs {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
align-items: flex-start;
}
.value-selector {
+73 -57
View File
@@ -1,9 +1,9 @@
import "@home-assistant/webawesome/dist/components/popup/popup";
import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { nextRender } from "../common/util/render-status";
export type ToastCloseReason =
@@ -22,9 +22,6 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@query("wa-popup")
private _popup?: WaPopup;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -48,7 +45,6 @@ export class HaToast extends LitElement {
clearTimeout(this._dismissTimer);
if (this._active && this._visible) {
this._popup?.reposition();
this._setDismissTimer();
return;
}
@@ -62,7 +58,7 @@ export class HaToast extends LitElement {
return;
}
this._popup?.reposition();
this._showToastPopover();
await nextRender();
if (transitionId !== this._transitionId) {
@@ -102,6 +98,7 @@ export class HaToast extends LitElement {
return;
}
this._hideToastPopover();
this._active = false;
await this.updateComplete;
@@ -123,6 +120,34 @@ export class HaToast extends LitElement {
}
}
private _isPopoverOpen(): boolean {
if (!this._toast || !popoverSupported) {
return false;
}
try {
return this._toast.matches(":popover-open");
} catch {
return false;
}
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
this._toast.showPopover?.();
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
this._toast.hidePopover?.();
}
private async _waitForTransitionEnd(): Promise<void> {
const toastEl = this._toast;
if (!toastEl) {
@@ -139,59 +164,46 @@ export class HaToast extends LitElement {
protected render() {
return html`
<wa-popup
placement="top"
.active=${this._active}
.distance=${16}
skidding="0"
flip
shift
<div
class=${classMap({
toast: true,
active: this._active,
visible: this._visible,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<div id="toast-anchor" slot="anchor" aria-hidden="true"></div>
<div
class=${classMap({
toast: true,
visible: this._visible,
})}
role="status"
aria-live="polite"
>
<span class="message">${this.labelText}</span>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
<span class="message">${this.labelText}</span>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
</wa-popup>
</div>
`;
}
static override styles = css`
#toast-anchor {
position: fixed;
bottom: calc(var(--ha-space-2) + var(--safe-area-inset-bottom));
inset-inline-start: 50%;
transform: translateX(-50%);
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
wa-popup::part(popup) {
padding: 0;
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
overflow: hidden;
}
.toast {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
);
inset-inline-start: 50%;
margin: 0;
width: max-content;
height: auto;
border: none;
overflow: hidden;
box-sizing: border-box;
min-width: min(
350px,
calc(
100vw - var(--ha-space-4) - var(--safe-area-inset-left) - var(
--safe-area-inset-right
100vw - var(--ha-space-4) - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
)
);
@@ -200,20 +212,25 @@ export class HaToast extends LitElement {
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
padding: var(--ha-space-3);
color: var(--ha-color-on-neutral-loud);
background-color: var(--ha-color-neutral-10);
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
opacity: 0;
transform: translateY(var(--ha-space-2));
transform: translate(-50%, var(--ha-space-2));
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
}
.toast:not(.active) {
display: none;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
transform: translate(-50%, 0);
}
.message {
@@ -229,13 +246,12 @@ export class HaToast extends LitElement {
}
@media all and (max-width: 450px), all and (max-height: 500px) {
wa-popup::part(popup) {
border-radius: var(--ha-border-radius-square);
}
.toast {
min-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
100vw - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
);
border-radius: var(--ha-border-radius-square);
}
+4
View File
@@ -620,6 +620,10 @@ export class HaInput extends LitElement {
background-color: var(--ha-color-form-background-disabled);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
wa-input::part(hint) {
height: var(--ha-space-5);
margin-block-start: 0;
@@ -535,7 +535,7 @@ export class HaTargetPickerItemRow extends LitElement {
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities)
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
? getEntityContext(
+1 -1
View File
@@ -15,7 +15,7 @@ interface HassioHardwareAudioList {
};
}
interface HardwareDevice {
export interface HardwareDevice {
attributes: Record<string, string>;
by_id: null | string;
dev_path: string;
+3 -1
View File
@@ -73,7 +73,6 @@ class DialogBox extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
@@ -104,6 +103,9 @@ class DialogBox extends LitElement {
: nothing}
${dialogTitle}
</span>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
+1
View File
@@ -5,6 +5,7 @@ interface BaseDialogBoxParams {
confirmText?: string;
text?: string | TemplateResult;
title?: string;
subtitle?: string;
warning?: boolean;
}
+2 -2
View File
@@ -536,9 +536,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities)
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry)
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
const deviceName = context?.device
+5 -1
View File
@@ -107,7 +107,11 @@ class MoreInfoContent extends LitElement {
if (!stateObj) {
return null;
}
const entityName = computeEntityName(stateObj, hass.entities);
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area } = getEntityContext(
stateObj,
hass.entities,
@@ -271,6 +271,7 @@ class DialogCalendarEventDetail extends LitElement {
color: var(--secondary-text-color);
max-width: 300px;
overflow-wrap: break-word;
white-space: pre-line;
}
`,
];
@@ -65,6 +65,9 @@ const processAreasForClimate = (
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
}
@@ -73,6 +76,9 @@ const processAreasForClimate = (
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
}
@@ -486,17 +486,6 @@ class SupervisorAppInfo extends LitElement {
<div class="description light-color">
${this.addon.description}.<br />
${this.hass.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this.addon.url!}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(this.addon.name, this.addon.stage)}</a
>`,
}
)}
</div>
<div class="addon-container">
<div>
@@ -9,7 +9,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { goBack, navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import type { HassioAddonDetails } from "../../../data/hassio/addon";
import { fetchHassioAddonInfo } from "../../../data/hassio/addon";
@@ -234,7 +234,7 @@ class HaConfigAppDashboard extends LitElement {
if (path === "uninstall") {
// Navigate back to installed apps after uninstall
window.history.back();
goBack(this._fromStore ? "/config/apps/available" : "/config/apps");
} else {
// Reload app info
await this._loadAddon();
@@ -185,6 +185,8 @@ export default class HaAutomationActionRow extends LitElement {
@state() private _collapsed = true;
@state() private _isNew = false;
@state() private _warnings?: string[];
@query("ha-automation-action-editor")
@@ -237,12 +239,13 @@ export default class HaAutomationActionRow extends LitElement {
private _renderRow() {
const type = getAutomationActionType(this.action);
const target =
type === "service" && "target" in this.action
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
const actionHasTarget = type === "service" && "target" in this.action;
const target = actionHasTarget
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
@@ -265,7 +268,9 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${target ? this._renderTargets(target) : nothing}
${target !== undefined || (actionHasTarget && !this._isNew)
? this._renderTargets(target, actionHasTarget && !this._isNew)
: nothing}
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
? html`<ha-svg-icon
@@ -575,10 +580,11 @@ export default class HaAutomationActionRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
></ha-automation-row-targets>`
);
@@ -812,6 +818,10 @@ export default class HaAutomationActionRow extends LitElement {
this.openSidebar();
}
public markAsNew(): void {
this._isNew = true;
}
public openSidebar(action?: Action): void {
const sidebarAction = action ?? this.action;
const actionType = getAutomationActionType(sidebarAction);
@@ -822,6 +832,7 @@ export default class HaAutomationActionRow extends LitElement {
},
close: (focus?: boolean) => {
this._selected = false;
this._isNew = false;
fireEvent(this, "close-sidebar");
if (focus) {
this.focus();
@@ -901,6 +912,9 @@ export default class HaAutomationActionRow extends LitElement {
);
private _toggleCollapse() {
if (!this._collapsed) {
this._isNew = false;
}
this._collapsed = !this._collapsed;
}
@@ -161,6 +161,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
if (mode === "new") {
row.expand();
row.markAsNew();
}
if (!this.optionsInSidebar) {
@@ -503,7 +503,7 @@ class DialogAddAutomationElement
const tabButtons = [
{
label: this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.name`
"ui.panel.config.automation.editor.tabs.type"
),
value: "groups",
},
@@ -511,7 +511,9 @@ class DialogAddAutomationElement
if (this._newTriggersAndConditions) {
tabButtons.unshift({
label: this.hass.localize(`ui.panel.config.automation.editor.targets`),
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
});
}
@@ -610,10 +612,10 @@ class DialogAddAutomationElement
.narrow=${this._narrow}
class=${classMap({
"ha-scrollbar": true,
[this._getAddFromTargetHidden(
hidden: !!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
)]: true,
),
})}
.manifests=${this._manifests}
></ha-automation-add-from-target>`
@@ -107,6 +107,8 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _collapsed = true;
@state() private _isNew = false;
@state() private _warnings?: string[];
@property({ attribute: false })
@@ -160,13 +162,15 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const target =
"target" in (this.conditionDescriptions[this.condition.condition] || {})
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const target = descriptionHasTarget
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
return html`
<ha-condition-icon
@@ -178,7 +182,9 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${target ? this._renderTargets(target) : nothing}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -464,10 +470,11 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
></ha-automation-row-targets>`
);
@@ -743,6 +750,10 @@ export default class HaAutomationConditionRow extends LitElement {
this.openSidebar();
}
public markAsNew(): void {
this._isNew = true;
}
public openSidebar(condition?: Condition): void {
const sidebarCondition = condition || this.condition;
fireEvent(this, "open-sidebar", {
@@ -751,6 +762,7 @@ export default class HaAutomationConditionRow extends LitElement {
},
close: (focus?: boolean) => {
this._selected = false;
this._isNew = false;
fireEvent(this, "close-sidebar");
if (focus) {
this.focus();
@@ -806,6 +818,9 @@ export default class HaAutomationConditionRow extends LitElement {
);
private _toggleCollapse() {
if (!this._collapsed) {
this._isNew = false;
}
this._collapsed = !this._collapsed;
}
@@ -169,6 +169,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
if (mode === "new") {
row.expand();
row.markAsNew();
}
if (!this.optionsInSidebar) {
@@ -1,13 +1,15 @@
import { consume } from "@lit/context";
import {
mdiAlert,
mdiAlertOctagon,
mdiCodeBraces,
mdiFormatListBulleted,
mdiShape,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
import { isTemplate } from "../../../../common/string/has-template";
@@ -35,6 +37,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public target?: HassServiceTarget;
@property({ attribute: false })
public targetRequired = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
@@ -73,13 +78,16 @@ export class HaAutomationRowTargets extends LitElement {
protected render() {
const length = Object.keys(this.target || {}).length;
if (!length) {
return html`<span class="target">
<div class="label">
${this.localize(
"ui.panel.config.automation.editor.target_summary.no_target"
)}
</div>
</span>`;
return this._renderTargetBadge(
this.targetRequired
? html`<ha-svg-icon .path=${mdiAlertOctagon}></ha-svg-icon>`
: nothing,
this.localize(
"ui.panel.config.automation.editor.target_summary.no_target"
),
false,
this.targetRequired
);
}
const totalLength = Object.values(this.target || {}).reduce(
(acc, val) => acc + ensureArray(val).length,
@@ -154,9 +162,10 @@ export class HaAutomationRowTargets extends LitElement {
private _renderTargetBadge(
icon: TemplateResult | typeof nothing,
label: string,
alert = false
warning = false,
error = false
) {
return html`<div class="target ${alert ? "alert" : ""}">
return html`<div class=${classMap({ target: true, warning, error })}>
${icon}
<div class="label">${label}</div>
</div>`;
@@ -230,10 +239,14 @@ export class HaAutomationRowTargets extends LitElement {
overflow: hidden;
height: 32px;
}
.target.alert {
.target.warning {
background: var(--ha-color-fill-warning-normal-resting);
color: var(--ha-color-on-warning-normal);
}
.target.error {
background: var(--ha-color-fill-danger-normal-resting);
color: var(--ha-color-on-danger-normal);
}
.target .label {
overflow: hidden;
text-overflow: ellipsis;
@@ -145,6 +145,8 @@ export default class HaAutomationTriggerRow extends LitElement {
@state() private _selected = false;
@state() private _isNew = false;
@state() private _warnings?: string[];
@property({ attribute: false })
@@ -197,14 +199,16 @@ export default class HaAutomationTriggerRow extends LitElement {
const yamlMode = this._yamlMode || !supported;
const target =
const descriptionHasTarget =
type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger];
const target = descriptionHasTarget
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
return html`
${type === "list"
@@ -220,7 +224,9 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target ? this._renderTargets(target) : nothing}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -446,7 +452,10 @@ export default class HaAutomationTriggerRow extends LitElement {
: nothing}${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel left-chevron>
<ha-expansion-panel
left-chevron
@expanded-changed=${this._expansionPanelChanged}
>
${this._renderRow()}
</ha-expansion-panel>
`}
@@ -467,10 +476,11 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
></ha-automation-row-targets>`
);
@@ -576,6 +586,16 @@ export default class HaAutomationTriggerRow extends LitElement {
this.openSidebar();
}
public markAsNew(): void {
this._isNew = true;
}
private _expansionPanelChanged(ev: CustomEvent) {
if (!ev.detail.expanded) {
this._isNew = false;
}
}
public openSidebar(trigger?: Trigger): void {
trigger = trigger || this.trigger;
fireEvent(this, "open-sidebar", {
@@ -584,6 +604,7 @@ export default class HaAutomationTriggerRow extends LitElement {
},
close: (focus?: boolean) => {
this._selected = false;
this._isNew = false;
fireEvent(this, "close-sidebar");
if (focus) {
this.focus();
@@ -252,6 +252,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
row.expand();
row.focus();
}
row.markAsNew();
});
}
}
@@ -18,7 +18,7 @@ import {
computeSection,
} from "../../../lovelace/common/generate-lovelace-config";
import { addEntitiesToLovelaceView } from "../../../lovelace/editor/add-entities-to-view";
import type { EntityRegistryEntryWithDisplayName } from "../ha-config-device-page";
import type { EntityRegistryStateEntry } from "../ha-config-device-page";
import { entityRowElement } from "../../../lovelace/entity-rows/entity-row-element-directive";
@customElement("ha-device-entities-card")
@@ -30,7 +30,7 @@ export class HaDeviceEntitiesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public entities!: EntityRegistryEntryWithDisplayName[];
public entities!: EntityRegistryStateEntry[];
@property({ attribute: "show-hidden", type: Boolean })
public showHidden = false;
@@ -115,10 +115,8 @@ export class HaDeviceEntitiesCard extends LitElement {
this.showHidden = !this.showHidden;
}
private _renderEntity(
entry: EntityRegistryEntryWithDisplayName
): TemplateResult {
let name = entry.display_name || this.deviceName;
private _renderEntity(entry: EntityRegistryStateEntry): TemplateResult {
let name = entry.stateName || this.deviceName;
if (entry.hidden_by) {
name += ` (${this.hass.localize(
"ui.panel.config.devices.entities.hidden"
@@ -130,9 +128,9 @@ export class HaDeviceEntitiesCard extends LitElement {
}
private _renderUnavailableEntity(
entry: EntityRegistryEntryWithDisplayName
entry: EntityRegistryStateEntry
): TemplateResult {
const name = entry.display_name || this.deviceName;
const name = entry.stateName || this.deviceName;
const icon = until(entryIcon(this.hass, entry));
@@ -34,7 +34,6 @@ import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -66,6 +65,7 @@ import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
findBatteryChargingEntity,
findBatteryEntity,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
@@ -94,9 +94,10 @@ import {
loadDeviceRegistryDetailDialog,
showDeviceRegistryDetailDialog,
} from "./device-registry-detail/show-dialog-device-registry-detail";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
export interface EntityRegistryEntryWithDisplayName extends EntityRegistryEntry {
display_name?: string | null;
export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string | null;
}
export interface DeviceAction {
@@ -176,17 +177,17 @@ export class HaConfigDevicePage extends LitElement {
(
deviceId: string,
entities: EntityRegistryEntry[]
): EntityRegistryEntry[] =>
): EntityRegistryStateEntry[] =>
entities
.filter((entity) => entity.device_id === deviceId)
.map((entity) => ({
...entity,
display_name: computeEntityEntryName(entity),
stateName: this._computeEntityName(entity),
}))
.sort((ent1, ent2) =>
stringCompare(
ent1.display_name || "",
ent2.display_name || "",
ent1.stateName || `zzz${ent1.entity_id}`,
ent2.stateName || `zzz${ent2.entity_id}`,
this.hass.locale.language
)
)
@@ -216,7 +217,7 @@ export class HaConfigDevicePage extends LitElement {
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
private _entityIds = memoizeOne(
(entries: EntityRegistryEntryWithDisplayName[]): string[] =>
(entries: EntityRegistryStateEntry[]): string[] =>
entries.map((entry) => entry.entity_id)
);
@@ -249,7 +250,7 @@ export class HaConfigDevicePage extends LitElement {
| "assist"
| "notify"
| NonNullable<EntityRegistryEntry["entity_category"]>,
EntityRegistryEntryWithDisplayName[]
EntityRegistryStateEntry[]
>;
for (const key of [
"assist",
@@ -1270,6 +1271,14 @@ export class HaConfigDevicePage extends LitElement {
}
}
private _computeEntityName(entity: EntityRegistryEntry) {
const device = this.hass.devices[this.deviceId];
return (
computeEntityEntryName(entity, this.hass.devices) ||
computeDeviceNameDisplay(device, this.hass)
);
}
private _onImageLoad(ev) {
ev.target.style.display = "inline-block";
}
@@ -1362,6 +1371,8 @@ export class HaConfigDevicePage extends LitElement {
showDeviceRegistryDetailDialog(this, {
device,
updateEntry: async (updates) => {
const oldDeviceName = device.name_by_user || device.name;
const newDeviceName = updates.name_by_user;
const disabled =
updates.disabled_by === "user" && device.disabled_by !== "user";
@@ -1433,7 +1444,43 @@ export class HaConfigDevicePage extends LitElement {
),
text: err.message,
});
return;
}
if (
!oldDeviceName ||
!newDeviceName ||
oldDeviceName === newDeviceName
) {
return;
}
const entities = this._entities(this.deviceId, this._entityReg);
const updateProms = entities.map((entity) => {
const name = entity.name || entity.stateName;
let newName: string | null | undefined;
if (entity.has_entity_name && !entity.name) {
return undefined;
}
if (
entity.has_entity_name &&
(entity.name === oldDeviceName || entity.name === newDeviceName)
) {
// clear name if it matches the device name and it uses the device name (entity naming)
newName = null;
} else if (name && name.includes(oldDeviceName)) {
newName = name.replace(oldDeviceName, newDeviceName);
} else {
return undefined;
}
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
name: newName,
});
});
await Promise.all(updateProms);
},
});
};
@@ -140,9 +140,9 @@ export class DialogVacuumSegmentMapping
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities)
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry)
? computeEntityEntryName(this._entry, this.hass.devices)
: this._params.entityId;
const deviceName = context?.device
@@ -164,7 +164,7 @@ export class EntitySettingsHelperTab extends LitElement {
}
private async _confirmDeleteItem(): Promise<void> {
const name = computeEntityEntryName(this.entry);
const name = computeEntityEntryName(this.entry, this.hass.devices);
const confirmationText = await getDeleteConfirmationText(
this.hass,
this.entry,
@@ -153,7 +153,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public helperConfigEntry?: ConfigEntry;
@state() private _name!: string | null;
@state() private _name!: string;
@state() private _icon!: string;
@@ -218,7 +218,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
return;
}
this._name = this.entry.name;
this._name = this.entry.name || "";
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
@@ -388,27 +388,14 @@ export class EntityRegistrySettingsEditor extends LitElement {
${this.hideName
? nothing
: html`<ha-textfield
class="name"
.value=${this._name ?? this.entry.original_name ?? ""}
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
.placeholder=${this.entry.original_name}
@input=${this._nameChanged}
.iconTrailing=${this._name !== null}
>
${this._name !== null
? html`<div class="layout horizontal" slot="trailingIcon">
<ha-icon-button
@click=${this._restoreName}
.path=${mdiRestore}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.restore_name"
)}
></ha-icon-button>
</div>`
: nothing}
</ha-textfield>`}
></ha-textfield>`}
${this.hideIcon
? nothing
: html`
@@ -1065,7 +1052,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._name?.trim() ?? null,
name: this._name.trim() || null,
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
@@ -1331,11 +1318,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._name = ev.target.value;
}
private _restoreName(): void {
fireEvent(this, "change");
this._name = null;
}
private _iconChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._icon = ev.detail.value;
@@ -1610,13 +1592,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
ha-textfield.entityId {
--text-field-prefix-padding-right: 0;
}
ha-textfield.entityId,
ha-textfield.name {
--textfield-icon-trailing-padding: 0;
}
ha-textfield.entityId ha-icon-button,
ha-textfield.name ha-icon-button {
ha-textfield.entityId ha-icon-button {
position: relative;
right: calc(var(--ha-space-2) * -1);
--ha-icon-button-size: 36px;
@@ -213,7 +213,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
}
private async _confirmDeleteEntry(): Promise<void> {
let name = computeEntityEntryName(this.entry);
let name = computeEntityEntryName(this.entry, this.hass.devices);
if (!name) {
const { device } = getEntityEntryContext(
this.entry,
@@ -32,10 +32,7 @@ import {
getDuplicatedDeviceNames,
} from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
computeEntityEntryName,
computeEntityName,
} from "../../../common/entity/compute_entity_name";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../common/entity/compute_state_name";
import {
deleteEntity,
@@ -693,9 +690,11 @@ export class HaConfigEntities extends LitElement {
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const entityName = entity
? computeEntityName(entity, this.hass.entities)
: computeEntityEntryName(entry as EntityRegistryEntry);
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
this.hass.devices,
entity
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
@@ -1,226 +0,0 @@
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-dialog";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-next";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHardwareInfo } from "../../../data/hassio/hardware";
import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
const _filterDevices = memoizeOne(
(
showAdvanced: boolean,
hardware: HassioHardwareInfo,
filter: string,
language: string
) =>
hardware.devices
.filter(
(device) =>
(showAdvanced ||
["tty", "gpio", "input"].includes(device.subsystem)) &&
(device.by_id?.toLowerCase().includes(filter) ||
device.name.toLowerCase().includes(filter) ||
device.dev_path.toLocaleLowerCase().includes(filter) ||
JSON.stringify(device.attributes)
.toLocaleLowerCase()
.includes(filter))
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
@customElement("ha-dialog-hardware-available")
class DialogHardwareAvailable extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _hardware?: HassioHardwareInfo;
@state() private _filter?: string;
@state() private _open = false;
public async showDialog(): Promise<Promise<void>> {
try {
this._hardware = await fetchHassioHardwareInfo(this.hass);
this._open = true;
} catch (err: any) {
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.available_hardware.failed_to_get"
),
text: extractApiErrorMessage(err),
});
}
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed() {
this._open = false;
this._hardware = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._hardware) {
return nothing;
}
const devices = _filterDevices(
this.hass.userData?.showAdvanced || false,
this._hardware,
(this._filter || "").toLowerCase(),
this.hass.locale.language
);
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
header-title=${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
@closed=${this._dialogClosed}
>
<div class="content-container">
<ha-input-search
appearance="outlined"
autofocus
.value=${this._filter}
@input=${this._handleSearchChange}
.placeholder=${this.hass.localize(
"ui.panel.config.hardware.available_hardware.search"
)}
>
</ha-input-search>
<div class="devices-container ha-scrollbar">
${devices.map(
(device) => html`
<ha-expansion-panel
.header=${device.name}
.secondary=${device.by_id || undefined}
outlined
>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.subsystem"
)}:
</span>
<span>${device.subsystem}</span>
</div>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.device_path"
)}:
</span>
<code>${device.dev_path}</code>
</div>
${device.by_id
? html`
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.id"
)}:
</span>
<code>${device.by_id}</code>
</div>
`
: nothing}
<div class="attributes">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.attributes"
)}:
</span>
<pre>${dump(device.attributes, { indent: 2 })}</pre>
</div>
</ha-expansion-panel>
`
)}
</div>
</div>
</ha-dialog>
`;
}
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.devices-container {
padding: var(--ha-space-6);
overflow-y: auto;
flex: 1;
min-height: 0;
}
ha-expansion-panel {
flex: 1;
margin: 4px 0;
}
pre,
code {
background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm);
}
pre {
padding: 16px;
overflow: auto;
line-height: var(--ha-line-height-normal);
font-family: var(--ha-font-family-code);
}
code {
font-size: var(--ha-font-size-s);
padding: 0.2em 0.4em;
}
ha-input-search {
padding: 0 var(--ha-space-5);
}
.device-property {
display: flex;
justify-content: space-between;
}
.attributes {
margin-top: 12px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-hardware-available": DialogHardwareAvailable;
}
}
@@ -0,0 +1,166 @@
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HardwareDevice,
HassioHardwareInfo,
} from "../../../data/hassio/hardware";
import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { hardwareTabs } from "./ha-config-hardware";
interface HardwareDeviceRow extends HardwareDevice {
id: string;
attributes_string: string;
}
@customElement("ha-config-hardware-all")
class HaConfigHardwareAll extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _hardware?: HassioHardwareInfo;
@state() private _error?: string;
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
name: {
title: localize("ui.panel.config.hardware.system_hardware.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
},
dev_path: {
title: localize("ui.panel.config.hardware.system_hardware.device_path"),
sortable: true,
filterable: true,
flex: 2,
},
by_id: {
title: localize("ui.panel.config.hardware.system_hardware.id"),
sortable: true,
filterable: true,
flex: 2,
},
subsystem: {
title: localize("ui.panel.config.hardware.system_hardware.subsystem"),
sortable: true,
filterable: true,
flex: 1,
},
attributes_string: {
title: "",
filterable: true,
hidden: true,
},
})
);
private _data = memoizeOne(
(showAdvanced: boolean, hardware: HassioHardwareInfo): DataTableRowData[] =>
hardware.devices
.filter(
(device) =>
showAdvanced || ["tty", "gpio", "input"].includes(device.subsystem)
)
.map((device) => ({
...device,
id: device.dev_path,
attributes_string: Object.entries(device.attributes)
.map(([key, value]) => `${key}: ${value}`)
.join(" "),
}))
);
protected firstUpdated(): void {
this._load();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.tabs=${hardwareTabs(this.hass)}
clickable
.columns=${this._columns(this.hass.localize)}
.data=${this._hardware
? this._data(
this.hass.userData?.showAdvanced || false,
this._hardware
)
: []}
.noDataText=${this._error ||
this.hass.localize("ui.panel.config.hardware.loading_system_data")}
@row-click=${this._handleRowClicked}
></hass-tabs-subpage-data-table>
`;
}
private async _load() {
try {
this._hardware = await fetchHassioHardwareInfo(this.hass);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private _handleRowClicked(ev: CustomEvent<RowClickedEvent>) {
const id = ev.detail.id;
const device = this._hardware?.devices.find((dev) => dev.dev_path === id);
if (!device) {
return;
}
showAlertDialog(this, {
title: device.name,
subtitle: this.hass.localize(
"ui.panel.config.hardware.system_hardware.attributes"
),
text: html`${until(this._renderHaCodeEditor(device))}`,
});
}
private async _renderHaCodeEditor(device: HardwareDevice) {
await import("../../../components/ha-code-editor");
return html`<ha-code-editor
mode="yaml"
.hass=${this.hass}
.value=${dump(device.attributes, { indent: 2 })}
read-only
></ha-code-editor>`;
}
static styles: CSSResultGroup = css`
ha-code-editor {
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-hardware-all": HaConfigHardwareAll;
}
}
@@ -0,0 +1,571 @@
import { mdiPower } from "@mdi/js";
import type { SeriesOption } from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../data/hardware";
import { BOARD_NAMES } from "../../../data/hardware";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import { scanUSBDevices } from "../../../data/usb";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { ECOption } from "../../../resources/echarts/echarts";
import { haStyle } from "../../../resources/styles";
import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals";
import type { HomeAssistant, Route } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { hardwareTabs } from "./ha-config-hardware";
const DATASAMPLES = 60;
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DefaultPrimaryColor,
areaStyle: {
color: DefaultPrimaryColor + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
};
@customElement("ha-config-hardware-overview")
class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _error?: string;
@state() private _OSData?: HassioHassOSInfo;
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
subscribeConfigEntries(
this.hass,
(messages) => {
let fullUpdate = false;
const newEntries: ConfigEntry[] = [];
messages.forEach((message) => {
if (message.type === null || message.type === "added") {
newEntries.push(message.entry);
if (message.type === null) {
fullUpdate = true;
}
} else if (message.type === "removed") {
if (this._configEntries) {
delete this._configEntries[message.entry.entry_id];
}
} else if (message.type === "updated") {
if (this._configEntries) {
const newEntry = message.entry;
this._configEntries[message.entry.entry_id] = newEntry;
}
}
});
if (!newEntries.length && !fullUpdate) {
return;
}
const entries = [
...(fullUpdate ? [] : Object.values(this._configEntries || {})),
...newEntries,
];
const configEntries: Record<string, ConfigEntry> = {};
for (const entry of entries) {
configEntries[entry.entry_id] = entry;
}
this._configEntries = configEntries;
},
{ type: ["hardware"] }
),
];
if (isComponentLoaded(this.hass, "hardware")) {
subs.push(
this.hass.connection.subscribeMessage<SystemStatusStreamMessage>(
(message) => {
// Only store the last 60 entries
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
{
type: "hardware/subscribe_system_status",
}
)
);
}
return subs;
}
protected willUpdate(): void {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
xAxis: {
type: "time",
},
yAxis: {
type: "value",
min: 0,
max: 100,
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._load();
const date = new Date();
// Force graph to start drawing from the right
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
protected render() {
let boardId: string | undefined;
let boardName: string | undefined;
let imageURL: string | undefined;
let documentationURL: string | undefined;
let boardConfigEntries: ConfigEntry[] = [];
const boardData = this._hardwareInfo?.hardware.find(
(hw) => hw.board !== null
);
const dongles = this._hardwareInfo?.hardware.filter(
(hw) =>
hw.dongle !== null &&
(!hw.config_entries.length ||
hw.config_entries.some(
(entryId) =>
this._configEntries?.[entryId] &&
!this._configEntries[entryId].disabled_by
))
);
if (boardData) {
boardConfigEntries = boardData.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
imageURL = hardwareBrandsUrl(
{
category: "boards",
manufacturer: boardData.board!.manufacturer,
model: boardData.board!.model,
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (this._OSData?.board) {
boardId = this._OSData.board;
boardName = BOARD_NAMES[this._OSData.board];
}
return html`
<hass-tabs-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${hardwareTabs(this.hass)}
>
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-icon-button
slot="toolbar-icon"
.path=${mdiPower}
.label=${this.hass.localize(
"ui.panel.config.hardware.restart_homeassistant"
)}
@click=${this._showRestartDialog}
></ha-icon-button>
`
: nothing}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
${boardName || isComponentLoaded(this.hass, "hassio")
? html`
<ha-card outlined>
<div class="card-content">
${imageURL
? html`<img
alt=""
src=${imageURL}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing}
<div class="board-info">
<p class="primary-text">
${boardName ||
this.hass.localize(
"ui.panel.config.hardware.generic_hardware"
)}
</p>
${boardId
? html`<p class="secondary-text">${boardId}</p>`
: nothing}
</div>
</div>
${documentationURL
? html`
<ha-md-list-item
.href=${documentationURL}
type="link"
target="_blank"
rel="noopener noreferrer"
>
<span
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
: nothing}
${boardConfigEntries.length
? html`<div class="card-actions">
<ha-button
.entry=${boardConfigEntries[0]}
@click=${this._openOptionsFlow}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`
: nothing}
${dongles?.length
? html`<ha-card outlined>
${dongles.map((dongle) => {
const configEntry = dongle.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
)[0];
return html`<div class="row">
${dongle.name}${configEntry
? html`<ha-button
.entry=${configEntry}
@click=${this._openOptionsFlow}
appearance="filled"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>`
: nothing}
</div>`;
})}
</ha-card>`
: nothing}
${isComponentLoaded(this.hass, "hardware")
? html`<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize(
"ui.panel.config.hardware.processor"
)}
</div>
<div class="value">
${this._systemStatusData
? html`${this._systemStatusData
.cpu_percent}${blankBeforePercent(
this.hass.locale
)}%`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html` <ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>`
: nothing}
</div>
</ha-card>
<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize("ui.panel.config.hardware.memory")}
</div>
<div class="value">
${this._systemStatusData
? html`${round(
this._systemStatusData.memory_used_mb / 1024,
1
)}
GB /
${round(
(this._systemStatusData.memory_used_mb +
this._systemStatusData.memory_free_mb) /
1024,
0
)}
GB`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html`
<ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>
`
: nothing}
</div>
</ha-card>`
: nothing}
</div>
</hass-tabs-subpage>
`;
}
private async _load() {
if (isComponentLoaded(this.hass, "usb")) {
await scanUSBDevices(this.hass);
}
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
try {
if (isComponentLoaded(this.hass, "hardware")) {
this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" });
}
if (isHassioLoaded && !this._hardwareInfo?.hardware.length) {
this._OSData = await fetchHassioHassOsInfo(this.hass);
}
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private async _openOptionsFlow(ev) {
const entry = ev.currentTarget.entry;
if (!entry) {
return;
}
showOptionsFlowDialog(this, entry);
}
private async _showRestartDialog() {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
--mdc-list-side-padding: 24px;
--mdc-list-vertical-padding: 0;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
margin-bottom: 16px;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px;
}
.loading-container {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(var(--rgb-card-background-color), 0.75);
display: flex;
justify-content: center;
align-items: center;
}
.card-content img {
max-width: 300px;
margin: auto;
}
.board-info {
text-align: center;
}
.primary-text {
font-size: var(--ha-font-size-l);
margin: 0;
}
.secondary-text {
font-size: var(--ha-font-size-m);
margin-bottom: 0;
color: var(--secondary-text-color);
}
.header {
padding: 16px;
display: flex;
justify-content: space-between;
}
.header .title {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-l);
}
.header .value {
font-size: var(--ha-font-size-l);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-alert {
--ha-alert-icon-size: 24px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-hardware-overview": HaConfigHardwareOverview;
}
}
+50 -569
View File
@@ -1,585 +1,66 @@
import { mdiPower } from "@mdi/js";
import type { SeriesOption } from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mdiFormatListBulletedType, mdiMemory } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../data/hardware";
import { BOARD_NAMES } from "../../../data/hardware";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import { scanUSBDevices } from "../../../data/usb";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { ECOption } from "../../../resources/echarts/echarts";
import { haStyle } from "../../../resources/styles";
import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
const DATASAMPLES = 60;
export const hardwareTabs = (hass: HomeAssistant): PageNavigation[] => {
const tabs: PageNavigation[] = [
{
path: "/config/hardware/overview",
translationKey: "ui.panel.config.hardware.overview",
iconPath: mdiMemory,
},
];
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DefaultPrimaryColor,
areaStyle: {
color: DefaultPrimaryColor + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
if (isComponentLoaded(hass, "hassio")) {
tabs.push({
path: "/config/hardware/all",
translationKey: "ui.panel.config.hardware.system_hardware.title",
iconPath: mdiFormatListBulletedType,
});
}
return tabs;
};
@customElement("ha-config-hardware")
class HaConfigHardware extends SubscribeMixin(LitElement) {
class HaConfigHardware extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _error?: string;
protected routerOptions: RouterOptions = {
defaultPage: "overview",
routes: {
overview: {
tag: "ha-config-hardware-overview",
load: () => import("./ha-config-hardware-overview"),
cache: true,
},
all: {
tag: "ha-config-hardware-all",
load: () => import("./ha-config-hardware-all"),
},
},
beforeRender: (page) => {
if (
page === "all" &&
(!this.hass || !isComponentLoaded(this.hass, "hassio"))
) {
return "overview";
}
return undefined;
},
};
@state() private _OSData?: HassioHassOSInfo;
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
subscribeConfigEntries(
this.hass,
(messages) => {
let fullUpdate = false;
const newEntries: ConfigEntry[] = [];
messages.forEach((message) => {
if (message.type === null || message.type === "added") {
newEntries.push(message.entry);
if (message.type === null) {
fullUpdate = true;
}
} else if (message.type === "removed") {
if (this._configEntries) {
delete this._configEntries[message.entry.entry_id];
}
} else if (message.type === "updated") {
if (this._configEntries) {
const newEntry = message.entry;
this._configEntries[message.entry.entry_id] = newEntry;
}
}
});
if (!newEntries.length && !fullUpdate) {
return;
}
const entries = [
...(fullUpdate ? [] : Object.values(this._configEntries || {})),
...newEntries,
];
const configEntries: Record<string, ConfigEntry> = {};
for (const entry of entries) {
configEntries[entry.entry_id] = entry;
}
this._configEntries = configEntries;
},
{ type: ["hardware"] }
),
];
if (isComponentLoaded(this.hass, "hardware")) {
subs.push(
this.hass.connection.subscribeMessage<SystemStatusStreamMessage>(
(message) => {
// Only store the last 60 entries
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
{
type: "hardware/subscribe_system_status",
}
)
);
}
return subs;
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.route = this.routeTail;
}
protected willUpdate(): void {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
xAxis: {
type: "time",
},
yAxis: {
type: "value",
min: 0,
max: 100,
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._load();
const date = new Date();
// Force graph to start drawing from the right
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
protected render() {
let boardId: string | undefined;
let boardName: string | undefined;
let imageURL: string | undefined;
let documentationURL: string | undefined;
let boardConfigEntries: ConfigEntry[] = [];
const boardData = this._hardwareInfo?.hardware.find(
(hw) => hw.board !== null
);
const dongles = this._hardwareInfo?.hardware.filter(
(hw) =>
hw.dongle !== null &&
(!hw.config_entries.length ||
hw.config_entries.some(
(entryId) =>
this._configEntries?.[entryId] &&
!this._configEntries[entryId].disabled_by
))
);
if (boardData) {
boardConfigEntries = boardData.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
imageURL = hardwareBrandsUrl(
{
category: "boards",
manufacturer: boardData.board!.manufacturer,
model: boardData.board!.model,
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (this._OSData?.board) {
boardId = this._OSData.board;
boardName = BOARD_NAMES[this._OSData.board];
}
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.hardware.caption")}
>
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-icon-button
slot="toolbar-icon"
.path=${mdiPower}
.label=${this.hass.localize(
"ui.panel.config.hardware.restart_homeassistant"
)}
@click=${this._showRestartDialog}
></ha-icon-button>
`
: nothing}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
${boardName || isComponentLoaded(this.hass, "hassio")
? html`
<ha-card outlined>
<div class="card-content">
${imageURL
? html`<img
alt=""
src=${imageURL}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing}
<div class="board-info">
<p class="primary-text">
${boardName ||
this.hass.localize(
"ui.panel.config.hardware.generic_hardware"
)}
</p>
${boardId
? html`<p class="secondary-text">${boardId}</p>`
: nothing}
</div>
</div>
${documentationURL
? html`
<ha-md-list-item
.href=${documentationURL}
type="link"
target="_blank"
rel="noopener noreferrer"
>
<span
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
: nothing}
${boardConfigEntries.length ||
isComponentLoaded(this.hass, "hassio")
? html`<div class="card-actions">
${boardConfigEntries.length
? html`
<ha-button
.entry=${boardConfigEntries[0]}
@click=${this._openOptionsFlow}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>
`
: nothing}
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-button
@click=${this._openHardware}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
</ha-button>
`
: nothing}
</div>`
: nothing}
</ha-card>
`
: nothing}
${dongles?.length
? html`<ha-card outlined>
${dongles.map((dongle) => {
const configEntry = dongle.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
)[0];
return html`<div class="row">
${dongle.name}${configEntry
? html`<ha-button
.entry=${configEntry}
@click=${this._openOptionsFlow}
appearance="filled"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>`
: nothing}
</div>`;
})}
</ha-card>`
: nothing}
${isComponentLoaded(this.hass, "hardware")
? html`<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize(
"ui.panel.config.hardware.processor"
)}
</div>
<div class="value">
${this._systemStatusData
? html`${this._systemStatusData
.cpu_percent}${blankBeforePercent(
this.hass.locale
)}%`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html` <ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>`
: nothing}
</div>
</ha-card>
<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize("ui.panel.config.hardware.memory")}
</div>
<div class="value">
${this._systemStatusData
? html`${round(
this._systemStatusData.memory_used_mb / 1024,
1
)}
GB /
${round(
(this._systemStatusData.memory_used_mb +
this._systemStatusData.memory_free_mb) /
1024,
0
)}
GB`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html`
<ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>
`
: nothing}
</div>
</ha-card>`
: nothing}
</div>
</hass-subpage>
`;
}
private async _load() {
if (isComponentLoaded(this.hass, "usb")) {
await scanUSBDevices(this.hass);
}
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
try {
if (isComponentLoaded(this.hass, "hardware")) {
this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" });
}
if (isHassioLoaded && !this._hardwareInfo?.hardware.length) {
this._OSData = await fetchHassioHassOsInfo(this.hass);
}
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private async _openOptionsFlow(ev) {
const entry = ev.currentTarget.entry;
if (!entry) {
return;
}
showOptionsFlowDialog(this, entry);
}
private async _openHardware() {
showhardwareAvailableDialog(this);
}
private async _showRestartDialog() {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
--mdc-list-side-padding: 24px;
--mdc-list-vertical-padding: 0;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
margin-bottom: 16px;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px;
}
.loading-container {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(var(--rgb-card-background-color), 0.75);
display: flex;
justify-content: center;
align-items: center;
}
.card-content img {
max-width: 300px;
margin: auto;
}
.board-info {
text-align: center;
}
.primary-text {
font-size: var(--ha-font-size-l);
margin: 0;
}
.secondary-text {
font-size: var(--ha-font-size-m);
margin-bottom: 0;
color: var(--secondary-text-color);
}
.header {
padding: 16px;
display: flex;
justify-content: space-between;
}
.header .title {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-l);
}
.header .value {
font-size: var(--ha-font-size-l);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-alert {
--ha-alert-icon-size: 24px;
}
`,
];
}
declare global {
@@ -1,12 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const loadHardwareAvailableDialog = () =>
import("./dialog-hardware-available");
export const showhardwareAvailableDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-hardware-available",
dialogImport: loadHardwareAvailableDialog,
dialogParams: {},
});
};
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeEntityEntryName } from "../../../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../../../common/entity/compute_state_name";
import { stringCompare } from "../../../../../common/string/compare";
import "../../../../../components/entity/state-badge";
import "../../../../../components/ha-area-picker";
@@ -12,13 +12,17 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-textfield";
import { updateDeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../../../data/entity/entity_registry";
import {
getAutomaticEntityIds,
subscribeEntityRegistry,
updateEntityRegistryEntry,
} from "../../../../../data/entity/entity_registry";
import type { ZHADevice } from "../../../../../data/zha";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { EntityRegistryEntryWithDisplayName } from "../../../devices/ha-config-device-page";
import type { EntityRegistryStateEntry } from "../../../devices/ha-config-device-page";
@customElement("zha-device-card")
class ZHADeviceCard extends SubscribeMixin(LitElement) {
@@ -34,17 +38,17 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
(
deviceId: string,
entities: EntityRegistryEntry[]
): EntityRegistryEntryWithDisplayName[] =>
): EntityRegistryStateEntry[] =>
entities
.filter((entity) => entity.device_id === deviceId)
.map((entity) => ({
...entity,
display_name: computeEntityEntryName(entity),
stateName: this._computeEntityName(entity),
}))
.sort((ent1, ent2) =>
stringCompare(
ent1.display_name || "",
ent2.display_name || "",
ent1.stateName || `zzz${ent1.entity_id}`,
ent2.stateName || `zzz${ent2.entity_id}`,
this.hass.locale.language
)
)
@@ -85,7 +89,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
? html`
<state-badge
@click=${this._openMoreInfo}
.title=${entity.display_name || ""}
.title=${entity.stateName!}
.hass=${this.hass}
.stateObj=${this.hass!.states[entity.entity_id]}
slot="item-icon"
@@ -116,11 +120,52 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
if (!this.hass || !this.device) {
return;
}
const device = this.device;
const oldDeviceName = device.user_given_name || device.name;
const newDeviceName = event.target.value;
this.device.user_given_name = newDeviceName;
await updateDeviceRegistryEntry(this.hass, this.device.device_reg_id, {
await updateDeviceRegistryEntry(this.hass, device.device_reg_id, {
name_by_user: newDeviceName,
});
if (!oldDeviceName || !newDeviceName || oldDeviceName === newDeviceName) {
return;
}
const entities = this._deviceEntities(device.device_reg_id, this._entities);
const entityIdsMapping = await getAutomaticEntityIds(
this.hass,
entities.map((entity) => entity.entity_id)
);
const updateProms = entities.map((entity) => {
const name = entity.name;
const newEntityId = entityIdsMapping[entity.entity_id];
let newName: string | null | undefined;
if (entity.has_entity_name && !entity.name) {
newName = undefined;
} else if (
entity.has_entity_name &&
(entity.name === oldDeviceName || entity.name === newDeviceName)
) {
// clear name if it matches the device name and it uses the device name (entity naming)
newName = null;
} else if (name && name.includes(oldDeviceName)) {
newName = name.replace(oldDeviceName, newDeviceName);
}
if (newName !== undefined && !newEntityId) {
return undefined;
}
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
name: newName,
new_entity_id: newEntityId || undefined,
});
});
await Promise.all(updateProms);
}
private _openMoreInfo(ev: MouseEvent): void {
@@ -129,6 +174,13 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
});
}
private _computeEntityName(entity: EntityRegistryEntry): string | null {
if (this.hass.states[entity.entity_id]) {
return computeStateName(this.hass.states[entity.entity_id]);
}
return entity.name;
}
private async _areaPicked(ev: CustomEvent) {
const picker = ev.currentTarget as any;
@@ -890,6 +890,8 @@ class DialogZWaveJSAddNode extends LitElement {
this._step = "rename_device";
const nameChanged = this._device.name !== this._deviceOptions?.name;
if (nameChanged || this._deviceOptions?.area) {
const oldDeviceName = this._device.name;
const newDeviceName = this._deviceOptions!.name;
try {
await updateDeviceRegistryEntry(this.hass, this._device.id, {
name_by_user: this._deviceOptions!.name,
@@ -897,6 +899,8 @@ class DialogZWaveJSAddNode extends LitElement {
});
if (nameChanged) {
// rename entities
const entities = this._entities.filter(
(entity) => entity.device_id === this._device!.id
);
@@ -908,14 +912,33 @@ class DialogZWaveJSAddNode extends LitElement {
await Promise.all(
entities.map((entity) => {
const name = entity.name;
let newName: string | null | undefined;
const newEntityId = entityIdsMapping[entity.entity_id];
if (!newEntityId || newEntityId === entity.entity_id) {
if (entity.has_entity_name && !entity.name) {
newName = undefined;
} else if (
entity.has_entity_name &&
(entity.name === oldDeviceName ||
entity.name === newDeviceName)
) {
// clear name if it matches the device name and it uses the device name (entity naming)
newName = null;
} else if (name && name.includes(oldDeviceName)) {
newName = name.replace(oldDeviceName, newDeviceName);
}
if (
(newName === undefined && !newEntityId) ||
newEntityId === entity.entity_id
) {
return undefined;
}
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
new_entity_id: newEntityId,
name: newName || name,
new_entity_id: newEntityId || undefined,
});
})
);
@@ -4,11 +4,11 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-spinner";
import "../../../components/ha-dialog";
import {
extractApiErrorMessage,
ignoreSupervisorError,
@@ -79,7 +79,7 @@ class MoveDatadiskDialog extends LitElement {
this.closeDialog();
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.available_hardware.failed_to_get"
"ui.panel.config.hardware.system_hardware.failed_to_get"
),
text: extractApiErrorMessage(err),
});
@@ -19,7 +19,6 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -175,11 +174,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
"--badge-color": color,
};
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const stateDisplay = html`
<state-display
@@ -542,6 +542,7 @@ export class HuiEnergyDevicesGraphCard
this._legendData = chartData.map((d) => ({
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
}));
// filter out hidden stats in place
for (let i = chartData.length - 1; i >= 0; i--) {
@@ -28,7 +28,6 @@ import {
subscribeEntityRegistry,
} from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { LovelaceCard } from "../types";
@@ -233,11 +232,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
const defaultCode = this._entry?.options?.alarm_control_panel?.default_code;
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
return html`
<ha-card>
+17 -4
View File
@@ -41,7 +41,6 @@ import type { FrontendLocaleData } from "../../../data/translation";
import type { Themes } from "../../../data/ws-themes";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasAction } from "../common/has-action";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -52,6 +51,21 @@ import type {
} from "../types";
import type { ButtonCardConfig } from "./types";
const EMPTY_STATE_OBJ = {
state: "unavailable",
attributes: {
friendly_name: "",
},
entity_id: "___.empty",
context: {
id: "",
parent_id: null,
user_id: null,
},
last_changed: "",
last_updated: "",
} satisfies HassEntity;
export const getEntityDefaultButtonAction = (entityId?: string) =>
entityId && DOMAINS_TOGGLE.has(computeDomain(entityId))
? "toggle"
@@ -183,9 +197,8 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
`;
}
const name = computeLovelaceEntityName(
this.hass,
stateObj,
const name = this.hass.formatEntityName(
stateObj || EMPTY_STATE_OBJ,
this._config.name
);
@@ -12,7 +12,6 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { normalizeValueBySIPrefix } from "../../../common/number/normalize-by-si-prefix";
import { MobileAwareMixin } from "../../../mixins/mobile-aware-mixin";
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-card";
import "../../../components/ha-segmented-bar";
@@ -240,7 +239,7 @@ export class HuiDistributionCard
const color = entity.color
? computeCssColor(entity.color)
: getGraphColorByIndex(entity.originalIndex, computedStyles);
const name = computeLovelaceEntityName(this.hass!, stateObj, entity.name);
const name = this.hass!.formatEntityName(stateObj, entity.name);
const formattedValue = this.hass!.formatEntityState(stateObj);
segments.push({
@@ -276,7 +275,7 @@ export class HuiDistributionCard
const isZeroOrNegative = !stateObj || value <= 0 || isNaN(value);
const name = stateObj
? computeLovelaceEntityName(this.hass!, stateObj, entity.name)
? this.hass!.formatEntityName(stateObj, entity.name)
: entity.entity;
const formattedValue = stateObj
+9 -8
View File
@@ -25,7 +25,6 @@ import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -143,14 +142,13 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
}
const indexUnit = stateParts.findIndex((part) => part.type === "unit");
const indexValue = stateParts.findIndex((part) => part.type === "value");
const indexValue = stateParts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
const reversedOrder = indexUnit !== -1 && indexUnit < indexValue;
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const colored = stateObj && this._getStateColor(stateObj, this._config);
@@ -209,7 +207,10 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
>
</ha-attribute-value>`
: this.hass.localize("state.default.unknown")
: stateParts.find((part) => part.type === "value")?.value}</span
: stateParts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("")}</span
>${unit
? html`<span
class=${classMap({
+1 -6
View File
@@ -14,7 +14,6 @@ import { UNAVAILABLE } from "../../../data/entity/entity";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
@@ -124,11 +123,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
`;
}
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
// Use `stateObj.state` as value to keep formatting (e.g trailing zeros)
// for consistent value display across gauge, entity, entity-row, etc.
+1 -6
View File
@@ -18,7 +18,6 @@ import type {
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
@@ -252,11 +251,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
</div>`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
entityConf.name
);
const name = this.hass!.formatEntityName(stateObj, entityConf.name);
return html`
<div
@@ -19,7 +19,6 @@ import {
import { fetchStatistics } from "../../../data/recorder";
import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
@@ -106,7 +105,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
this._entities.forEach((entity) => {
const stateObj = this.hass!.states[entity.entity];
this._names[entity.entity] = stateObj
? computeLovelaceEntityName(this.hass!, stateObj, entity.name)
? this.hass!.formatEntityName(stateObj, entity.name)
: entity.entity;
});
}
@@ -14,7 +14,6 @@ import "../../../state-control/humidifier/ha-state-control-humidifier-humidity";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
@@ -133,11 +132,7 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
`;
}
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const color = stateColorCss(stateObj);
+1 -6
View File
@@ -17,7 +17,6 @@ import { lightSupportsBrightness } from "../../../data/light";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -92,11 +91,7 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
((stateObj.attributes.brightness || 0) / 255) * 100
);
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
return html`
<ha-card>
+4 -2
View File
@@ -424,8 +424,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
const computedStyles = getComputedStyle(this);
color = getColorByIndex(this._colorIndex, computedStyles);
this._colorIndex++;
this._colorDict[entityId] = color;
if (color) {
this._colorIndex++;
this._colorDict[entityId] = color;
}
return color;
}
@@ -36,7 +36,6 @@ import {
mediaPlayerPlayMedia,
} from "../../../data/media-player";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-marquee";
@@ -242,8 +241,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
.hass=${this.hass}
></ha-state-icon>
<div>
${computeLovelaceEntityName(
this.hass,
${this.hass.formatEntityName(
this.hass!.states[this._config!.entity],
this._config.name
)}
@@ -13,7 +13,6 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { PersonEntity } from "../../../data/person";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -126,11 +125,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
`;
}
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const entityState = this.hass.formatEntityState(stateObj);
let footer: TemplateResult | string = "";
@@ -15,7 +15,6 @@ import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -119,7 +118,7 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
style="background-image:url(${stateObj.attributes.entity_picture})"
>
<div class="header">
${computeLovelaceEntityName(this.hass, stateObj, this._config.name)}
${this.hass.formatEntityName(stateObj, this._config.name)}
</div>
</div>
<div class="content">
+4 -22
View File
@@ -2,18 +2,13 @@ import { LitElement, css, html, nothing } from "lit";
import { property, state } from "lit/decorators";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import "./hui-card";
import type { HuiCard } from "./hui-card";
import type { StackCardConfig } from "./types";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { HuiErrorCard } from "./hui-error-card";
import type { StackCardConfig } from "./types";
export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
extends LitElement
@@ -44,14 +39,6 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
return 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: "auto",
min_columns: 3,
};
}
public setConfig(config: T): void {
if (!config || !config.cards || !Array.isArray(config.cards)) {
throw new Error("Invalid configuration");
@@ -117,11 +104,7 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div
id="root"
class="ha-scrollbar"
dir=${this.hass ? computeRTLDirection(this.hass) : "ltr"}
>
<div id="root" dir=${this.hass ? computeRTLDirection(this.hass) : "ltr"}>
${this._cards}
${this.preview && this._errorCard ? this._errorCard : nothing}
</div>
@@ -129,7 +112,6 @@ export abstract class HuiStackCard<T extends StackCardConfig = StackCardConfig>
}
static sharedStyles = [
haStyleScrollbar,
css`
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
@@ -23,7 +23,6 @@ import {
} from "../../../data/recorder";
import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
@@ -200,7 +199,7 @@ export class HuiStatisticCard extends LitElement implements LovelaceCard {
const stateObj = this.hass.states[this._config.entity];
const name =
(this._config.name
? computeLovelaceEntityName(this.hass, stateObj, this._config.name)
? this.hass.formatEntityName(stateObj, this._config.name)
: "") ||
getStatisticLabel(this.hass, this._config.entity, this._metadata);
@@ -24,7 +24,6 @@ import {
getStatisticMetadata,
} from "../../../data/recorder";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
@@ -189,8 +188,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
this._entities.forEach((config) => {
const stateObj = this.hass!.states[config.entity];
this._names[config.entity] =
computeLovelaceEntityName(this.hass!, stateObj, config.name) ||
config.entity;
this.hass!.formatEntityName(stateObj, config.name) || config.entity;
});
}
@@ -15,7 +15,6 @@ import "../../../state-control/water_heater/ha-state-control-water_heater-temper
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
@@ -132,11 +131,7 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
}
const domain = computeDomain(stateObj.entity_id);
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const color = stateColorCss(stateObj);
+1 -6
View File
@@ -23,7 +23,6 @@ import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -252,11 +251,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
`;
}
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color);
@@ -29,7 +29,6 @@ import {
} from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -231,7 +230,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
return html`
<ha-card class="unavailable" @click=${this._handleAction}>
${this.hass.localize("ui.panel.lovelace.warning.entity_unavailable", {
entity: `${computeLovelaceEntityName(this.hass, stateObj, this._config.name)} (${this._config.entity})`,
entity: `${this.hass.formatEntityName(stateObj, this._config.name)} (${this._config.entity})`,
})}
</ha-card>
`;
@@ -262,11 +261,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
const dayNight = forecastData?.type === "twice_daily";
const weatherStateIcon = getWeatherStateIcon(stateObj.state, this);
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
const temperatureFractionDigits = this._config.round_temperature
? 0
+1 -1
View File
@@ -155,7 +155,7 @@ export interface AreaCardConfig extends LovelaceCardConfig {
export interface ButtonCardConfig extends LovelaceCardConfig {
entity?: string;
name?: string;
name?: string | EntityNameItem | EntityNameItem[];
show_name?: boolean;
icon?: string;
icon_height?: string;
@@ -1,41 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../../../../common/array/ensure-array";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
} from "../../../../common/entity/compute_entity_name_display";
import type { HomeAssistant } from "../../../../types";
/**
* Computes the display name for an entity in Lovelace (cards and badges).
*
* @param hass - The Home Assistant instance
* @param stateObj - The entity state object
* @param config - The name configuration (string for override, or EntityNameItem[] for structured naming)
* @returns The computed entity name
*/
export const computeLovelaceEntityName = (
hass: HomeAssistant,
stateObj: HassEntity | undefined,
config: string | EntityNameItem | EntityNameItem[] | undefined
): string => {
if (typeof config === "string") {
return config;
}
if (stateObj) {
return hass.formatEntityName(stateObj, config ?? DEFAULT_ENTITY_NAME);
}
if (!config) {
return "";
}
// If entity is not found, fall back to text parts in config
// This allows for static names even when the entity is missing
// e.g. for a card that doesn't require an entity
const textParts = ensureArray(config)
.filter((item) => item.type === "text")
.map((item) => ("text" in item ? item.text : ""));
if (textParts.length) {
return textParts.join(" ");
}
return "";
};
@@ -32,7 +32,6 @@ import type {
} from "../cards/types";
import type { EntityConfig } from "../entity-rows/types";
import type { ButtonsHeaderFooterConfig } from "../header-footer/types";
import { computeLovelaceEntityName } from "./entity/compute-lovelace-entity-name";
const HIDE_DOMAIN = new Set([
"ai_task",
@@ -271,14 +270,14 @@ export const computeCards = (
? computeStateName(states[a])
: ""
: states[a.entity]
? computeLovelaceEntityName(hass, states[a.entity], a.name)
? hass.formatEntityName(states[a.entity], a.name)
: "",
typeof b === "string"
? states[b]
? computeStateName(states[b])
: ""
: states[b.entity]
? computeLovelaceEntityName(hass, states[b.entity], b.name)
? hass.formatEntityName(states[b.entity], b.name)
: ""
);
});
@@ -42,7 +42,8 @@ export class HuiEntityEditor extends LitElement {
const stateObj = this.hass.states[item.entity];
const useDeviceName =
stateObj && entityUseDeviceName(stateObj, this.hass.entities);
stateObj &&
entityUseDeviceName(stateObj, this.hass.entities, this.hass.devices);
const isRTL = computeRTL(this.hass);
@@ -18,7 +18,6 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { createEntityNotFoundWarning } from "./hui-warning";
@@ -66,11 +65,7 @@ export class HuiGenericEntityRow extends LitElement {
const pointer = hasAnyAction(this.config);
const hasSecondary = this.secondaryText || this.config.secondary_info;
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this.config.name
);
const name = this.hass.formatEntityName(stateObj, this.config.name);
return html`
<div
@@ -197,10 +197,10 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
disabled: this._shouldDisableOptions(entityId),
selector: {
attribute: {
entity_id: entityId,
hide_attributes: ["entity_picture", "friendly_name", "icon"],
},
},
context: { filter_entity: "entity" },
},
{
name: "unit",
@@ -293,6 +293,7 @@ export class HuiTileCardEditor
const featureContext = this._featureContext(entityId);
const hasCompatibleFeatures = this._hasCompatibleFeatures(featureContext);
const hasFeatures = (this._config.features?.length ?? 0) > 0;
return html`
<ha-form
@@ -311,7 +312,14 @@ export class HuiTileCardEditor
)}
</h3>
<div class="content">
${hasCompatibleFeatures
<hui-card-features-editor
.hass=${this.hass}
.context=${featureContext}
.features=${this._config!.features ?? []}
@features-changed=${this._featuresChanged}
@edit-detail-element=${this._editDetailElement}
></hui-card-features-editor>
${hasCompatibleFeatures && hasFeatures
? html`
<ha-form
class="features-form"
@@ -324,13 +332,6 @@ export class HuiTileCardEditor
></ha-form>
`
: nothing}
<hui-card-features-editor
.hass=${this.hass}
.context=${featureContext}
.features=${this._config!.features ?? []}
@features-changed=${this._featuresChanged}
@edit-detail-element=${this._editDetailElement}
></hui-card-features-editor>
</div>
</ha-expansion-panel>
`;
@@ -478,7 +479,8 @@ export class HuiTileCardEditor
margin-bottom: 8px;
}
.features-form {
margin-bottom: 8px;
margin-top: var(--ha-space-6);
margin-bottom: 0;
}
`,
];
@@ -7,7 +7,6 @@ import "../../../components/ha-time-input";
import { setDateTimeValue } from "../../../data/datetime";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -53,11 +52,7 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow {
const time = dateObj ? format(dateObj, "HH:mm:ss") : undefined;
const date = dateObj ? format(dateObj, "yyyy-MM-dd") : undefined;
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -9,7 +9,6 @@ import {
stateToIsoDateString,
} from "../../../data/input_datetime";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -47,11 +46,7 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -9,7 +9,6 @@ import type { InputSelectEntity } from "../../../data/input_select";
import { setInputSelectOption } from "../../../data/input_select";
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -50,11 +49,7 @@ class HuiInputSelectEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -5,7 +5,6 @@ import "../../../components/ha-textfield";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { setValue } from "../../../data/input_text";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -43,11 +42,7 @@ class HuiInputTextEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -9,7 +9,6 @@ import type { SelectEntity } from "../../../data/select";
import { setSelectOption } from "../../../data/select";
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -68,11 +67,7 @@ class HuiSelectEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -6,7 +6,6 @@ import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import type { TextEntity } from "../../../data/text";
import { setValue } from "../../../data/text";
import type { HomeAssistant } from "../../../types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import "../components/hui-generic-entity-row";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -46,11 +45,7 @@ class HuiTextEntityRow extends LitElement implements LovelaceRow {
`;
}
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<hui-generic-entity-row
@@ -17,7 +17,6 @@ import {
import type { HomeAssistant } from "../../../types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasConfigOrEntityChanged } from "../common/has-changed";
@@ -119,11 +118,7 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
const forecastData = getForecast(stateObj.attributes, this._forecastEvent);
const forecast = forecastData?.forecast;
const name = computeLovelaceEntityName(
this.hass!,
stateObj,
this._config.name
);
const name = this.hass!.formatEntityName(stateObj, this._config.name);
return html`
<div
@@ -15,7 +15,6 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
@@ -154,11 +153,7 @@ export class HuiEntityHeadingBadge
"--icon-color": color,
};
const name = computeLovelaceEntityName(
this.hass,
stateObj,
this._config.name
);
const name = this.hass.formatEntityName(stateObj, this._config.name);
return html`
<ha-heading-badge

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