Compare commits

...
22 Commits
Author SHA1 Message Date
Bram KragtenandGitHub f23c8f0e23 Fix sub device tree indicator (#53638) 2026-08-14 09:18:06 +01:00
Petar PetrovandGitHub f578cb0526 Guard registry lookups in the target picker item row (#53637) 2026-08-14 09:55:54 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
78878897cc Update dependency typescript-eslint to v8.67.0 (#53634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 06:46:42 +02:00
9472d8ad12 Add device context to energy individual device names (#53603)
Add device context to energy individual device labels

Co-authored-by: Simon Lamon <[email protected]>
2026-08-13 18:52:28 +02:00
Paul BotteinandGitHub 94cbe4467b Lay out inline features in two columns (#53631)
* Lay out inline features in two columns

* Detect stacked features from the slot

* Rename tile container layout classes

* Rename the below features in the layout helper

* Keep area card features below at full width

* Stretch the area card features below the inline one

* Keep area card features compact outside the compact type

* Revert "Keep area card features compact outside the compact type"

This reverts commit 48268cb4e2.

* Reapply "Keep area card features compact outside the compact type"

This reverts commit e10bb27e2f.

* Keep the inline feature compact on condensed cards
2026-08-13 18:00:47 +03:00
Paul BotteinandGitHub 043abb69af Keep control select in the tab order without a selection (#53633) 2026-08-13 15:54:59 +01:00
Paul BotteinandGitHub b6b8fe641f Fix clipped focus ring on media player playback buttons (#53632) 2026-08-13 15:54:49 +01:00
70bb08b101 Child devices UI (#53619)
* Add child devices UI

Surface child devices throughout the config UI so they read as first-class
devices nested under their parent:

- Device page: a "Sub-devices" card lists a device's children, and a child's
  page shows a "Part of <parent>" link (hardware/model/config-entry are
  already inherited from the parent by the registry resolver).
- Integration page: children are nested and indented under their parent
  device in the config-entry and subentry device lists.
- Device picker: children are ordered and indented under their parent with a
  tree connector, mirroring the area/floor picker.
- Naming: the device picker's secondary label and search now include the
  parent device name for children, so they stay identifiable in flat views.

Follow-up to the child devices data layer (#53617).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Add child devices to demo and gallery mock data

A power strip parent with two outlet children in the demo device stubs and
the ha-selector gallery demo, so child device rendering (nesting, tree
indentation, parent-context naming, inherited area) can be exercised without
a running backend.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Refine child devices UI and target resolution

Follow-up polish so child devices behave like a normal device everywhere:

- Targets & filters: a parent device qualifies for and resolves to its
  children's entities (getDevices filter, deviceMeetsFilter,
  deviceMeetsTargetSelector, resolveEntityIDs and the target-chip "split into
  entities" expand), matching core's server-side target resolution. Selecting a
  parent excludes its children from the picker.
- Pickers: keep a parent visible when a child matches the search (device and
  target pickers), fix the target picker's nested order (unsorted search +
  recomputed last-child flag), and render the sub-device tree in the target
  picker's device group.
- Area/naming: the area field shows only the (inherited) area again; the parent
  name remains a search term.
- Integration page: correct the tree end connector and align it in narrow mode.
- A parent-disabled child can no longer be enabled from the settings dialog.
- Devices dashboard: show "Part of <parent>" under a sub-device's name and add a
  hidden-by-default, searchable and groupable Parent device column that groups a
  parent together with its children.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address review on child devices UI

Align target resolution with core and fix picker/table details:

- Only a directly targeted device expands to its child devices. Labels are
  never inherited by children (core's target helper is explicit about this) and
  areas resolve by effective-area membership, so deviceMeetsFilter and
  deviceMeetsTargetSelector evaluate a device's own entities again.
- Add devicesInEffectiveArea, mirroring core's dr.async_entries_for_area: an
  area contains its devices plus children that inherit the area, but not a child
  with a different explicit area. Used for area expansion, area matching and the
  area chip's split action.
- Device picker: add a searchFn that restores the nested parent/child order
  after the fuzzy search and recomputes the last visible child, so a child can
  no longer be ranked above its parent and connectors stay correct.
- Devices dashboard: derive the family group name from the family's parent
  device with the same fallback for parents and children, so an unnamed parent
  cannot end up in a different group than its children.
- Child devices card: pass the device registry so a child shows its inherited
  area.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Fix formatting in device picker row renderer

Reindent the row renderer template after it gained a block body, so Prettier
is satisfied.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Make the child device tree connector span the full row

The connector was a fixed 48px box centred in a taller two-line row, and its
SVG was letterboxed by the default preserveAspectRatio, so the dashed line
stopped short of the row edges and consecutive children never visually
connected.

Let the indicator stretch (preserveAspectRatio="none") and give it the full row
height, so the line runs edge to edge with the elbow at the vertical centre.
non-scaling-stroke keeps the line width and dash pattern identical however far
it is stretched; existing 48x48 usages render unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 17:45:31 +03:00
4259ddee24 Show remaining tile card features below when position is inline (#53310)
* Show remaining tile card features below when position is inline

Previously the tile card silently dropped every feature after the first
one when "features_position" was set to "inline". The first feature is
now rendered next to the name as before, and any additional features are
stacked underneath, the same way they are in "bottom" position.

The tile container gained a "features-bottom" slot for this, and the
card size and grid options now account for the extra rows.

* Add tests for hui-tile-card size and grid options calculations

* Enhance hui-tile-card to support inline feature layout and improve feature counting logic

* reduce vertical gap for inline features

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-13 15:45:00 +02:00
6c5ac1ea84 Add background editor to dashboard detail editor (#51644)
* Setup

* Use same slot layout

* Fill tabs

* Move content rendering to dedicated function

* Self review

* Save dashboard details before background

Co-authored-by: timmo001 <[email protected]>

* Protect strategy dashboard config

* Apply dashboard background review fixes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-13 16:27:34 +03:00
Petar PetrovandGitHub 5224fa2048 Seed selector values when enabling optional service fields (#53629) 2026-08-13 15:27:07 +02:00
22ca986cce Skip source maps in the gallery build for e2e (#53625)
The gallery build is the only e2e build that still emits source maps: unlike
demo and the e2e test app, `bundle.config.gallery` never accepted
`isTestBuild`, so the production build always used `nosources-source-map` —
even though the artifact is only ever loaded by Playwright.

The e2e run's gallery artifact contains 693 `.map` files (14 MB of a 105 MB
dist); the demo artifact, which already passes `is-test`, contains none.

Thread `isTestBuild` through the gallery config and set `is-test: true` for the
e2e gallery build, so those maps are no longer generated. The design
deployment and preview workflows do not set `IS_TEST`, so they keep their
source maps.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 13:11:23 +03:00
7f8bf69424 List running automations & scripts in restart dialog (#53523)
* List running automations & scripts in restart dialog

* Don't need a new dialog

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <[email protected]>

* Show running automations in quick bar restart confirmation

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Norbert Rittel <[email protected]>
2026-08-13 09:10:23 +00:00
Aidan TimsonandGitHub 92224411e1 Add context to entity details (#53624)
* Add context to entity details

* Include entity context in YAML details

* Remove category from entity details

* Only link integrations with config entries
2026-08-13 12:04:12 +03:00
b52d58eccb Always group updates page entities by integration (#53480)
* Always group updates page entities by integration

Every integration now gets its own card on the updates page, titled with
the integration name, even when it only has a single update entity. The
catch-all Integrations card is removed. The Update all button is only
shown for cards with more than one entity.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DTuGyvS1i7w1B1xEGCEVhs

* Show update button on cards with a single update

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Paul Bottein <[email protected]>
2026-08-13 08:59:25 +00:00
a9cc47888a Add child devices to the device registry (#53617)
* Add child devices to the device registry

Child devices arrive over the WebSocket as stripped entries in the flat
config/device_registry/list response (discriminated by the absence of
full-device fields). Resolve them into complete DeviceRegistryEntry
objects at ingestion so hass.devices only ever holds full entries and the
~200 downstream consumers stay unchanged:

- config-entry association comes from the child's own config_entry_id, so
  children still appear under their integration;
- hardware/display fields are inherited from the parent device;
- connections/via_device_id are not inherited.

Adds parent_device_id and the new "device" disabled_by value to the types.

Frontend data layer for home-assistant/core#178666.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Resolve child device effective area in the UI

A child device without an area of its own inherits its parent's area,
mirroring core's async_get_effective_area_id. getDeviceArea now takes the
devices map and falls back to the parent, so children show in the parent's
area everywhere a normal device would: the device dashboard (area column,
grouping and filtering via computeDeviceAreaLabel) and the device page
header.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address review on child devices data layer

- Discriminate stripped child entries on `connections` (present on every full
  device, never on a stripped child) instead of the deprecated `config_entries`
  compatibility field that core plans to remove.
- Make getDeviceArea's `devices` argument required and update all callers, so a
  child device's effective (inherited) area is resolved consistently everywhere,
  including the device picker's selected-value renderer and the integration
  device rows.
- Lock the enable switch in the device settings dialog when a device is disabled
  by its parent (disabled_by "device"); core ignores enabling a child while its
  parent is disabled.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Inherit the parent device area when resolving entity areas

Extract getDeviceAreaId so an entity on a child device resolves to the child's
effective area (the parent's area when the child has none), matching core's
entity area resolution. Applies to both getEntityAreaId and
getEntityEntryContext, which previously read device.area_id directly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 11:57:28 +03:00
Paul BotteinandGitHub ea0ceecbfc Add ha-grouped-list component (#53618) 2026-08-13 08:47:14 +01:00
5f007a1575 Run the frontend build in parallel with lint and tests (#53623)
* Run the frontend build in parallel with lint and tests

The build job waited for lint and test because a full build was expensive
enough that we did not want to spend it on a PR that fails its checks. With
the rspack persistent cache it now takes ~3 min instead of ~5, and it is the
longest job in the run, so serialising it behind the others dominates CI
wall-clock: 8s + max(lint 86s, test 123s) + build 194s.

Depend only on prepare-dependencies so all three run together, which brings a
successful run down from ~5.5 min to ~3.5 min — the build itself becomes the
floor.

To avoid finishing an expensive build for a PR that is already broken, each of
the three jobs cancels the whole run when it fails. The cancel step needs
`actions: write`; on pull requests from forks the token stays read-only, so it
is a no-op there and the jobs just run to completion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Keep the Actions write scope away from pull request code

The cancellation needed `actions: write`, and granting it at workflow scope
handed it to every job — including the ones that check out the pull request
and pass GITHUB_TOKEN into the gulp build, so PR-controlled code (or a
compromised dependency) would have had write access to Actions.

Move the cancellation into its own job that holds `actions: write` on its own
and never checks out the repository, so the elevated token is never exposed to
PR code. It cannot simply `needs` the checks — a dependent job only starts once
they have all finished, which is too late to cancel anything — so it polls the
run's job statuses and cancels on the first failure.

Costs one extra (idle) runner slot for the duration of the run.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 08:48:24 +03:00
Bram KragtenandGitHub f360a22927 Wait for analytics data during onboarding (#53621) 2026-08-13 06:56:16 +02:00
Paul BotteinandGitHub a67111e41f Render state and numeric state entities as row targets (#53610)
* Render state and numeric state entities as row targets

* Add tests for entity-less trigger and condition descriptions
2026-08-13 06:53:44 +02:00
4b7d3a7e4f Add rspack persistent cache (nightly writes, CI reads) (#53611)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-12 22:30:42 +02:00
Petar PetrovandGitHub 88be7adafa Stop number box rows from reserving unused space (#53614) 2026-08-12 14:42:09 +01:00
105 changed files with 4130 additions and 816 deletions
+4
View File
@@ -11,6 +11,9 @@ inputs:
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
rspack-cache:
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
default: ""
runs:
using: composite
@@ -21,3 +24,4 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
RSPACK_CACHE: ${{ inputs.rspack-cache }}
+71 -4
View File
@@ -97,10 +97,12 @@ jobs:
run: yarn run test
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
# Runs alongside lint and test rather than after them: the build only needs
# the dependency tree, and with the rspack cache it is no longer expensive
# enough to be worth serialising behind the other checks. The
# cancel-on-failure job below stops the run as soon as a check fails, so a
# broken pull request does not finish building.
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -111,12 +113,26 @@ jobs:
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
# nightly.yaml). rspack itself decides what is still valid (version +
# buildDependencies + node_modules snapshot), so the GHA key just restores
# the latest nightly cache; no fingerprint, and no save step (CI never
# writes the shared cache).
- name: Restore rspack cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -132,3 +148,54 @@ jobs:
path: hass_frontend/
if-no-files-found: error
retention-days: 7
# Now that the checks run in parallel, a failing lint or test no longer stops
# the build from finishing on its own, so this watches them and cancels the
# whole run on the first failure.
#
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
# other jobs check out the pull request and run its build scripts — handing
# them that scope would give PR-controlled code (or a compromised dependency)
# write access to Actions. This job never checks out the repository, so the
# elevated token stays away from PR code. It also cannot be a job that
# `needs` the checks: that would only start once they have all finished, which
# is exactly too late to cancel anything.
cancel-on-failure:
name: Cancel run on failure
needs: prepare-dependencies
runs-on: ubuntu-latest
permissions:
actions: write
timeout-minutes: 30
steps:
- name: Cancel the run when a check fails
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
watched='^(Lint and check format|Run tests|Build frontend)$'
while :; do
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
2>/dev/null || true)
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
if [ -n "$failed" ]; then
echo "Cancelling the run, these checks failed:"
printf '%s\n' "$failed"
gh run cancel "$RUN_ID" --repo "$REPO" || true
exit 0
fi
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && $2 != "completed" { print $1 }')
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
echo "All checks finished without failure"
exit 0
fi
sleep 15
done
+1
View File
@@ -137,6 +137,7 @@ jobs:
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+24 -1
View File
@@ -58,9 +58,24 @@ jobs:
restore-keys: |
compress-cache-${{ runner.os }}-
# The nightly writes the rspack persistent cache; CI reads it read-only
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
# + node_modules snapshot), so the cache rolls forward daily and a single
# dependency bump keeps most of it warm instead of dropping the lineage.
- name: Restore rspack cache
id: rspack-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build nightly Python wheels
env:
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
RSPACK_CACHE: readwrite
run: |
pip install build
yarn install
@@ -69,7 +84,7 @@ jobs:
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
# Not gated on the restore step: a transient restore failure (it is
# Not gated on the restore steps: a transient restore failure (they are
# continue-on-error) must not stop us persisting a freshly built cache.
- name: Save compression cache
if: success()
@@ -79,6 +94,14 @@ jobs:
path: .compress-cache
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Save rspack cache
if: success()
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .rspack-cache
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+1
View File
@@ -7,6 +7,7 @@ dist/
/hass_frontend/
/translations/
/.compress-cache/
/.rspack-cache/
# Composite action source, not build output
!/.github/actions/build/
+2 -1
View File
@@ -277,7 +277,7 @@ module.exports.config = {
};
},
gallery({ isProdBuild, latestBuild }) {
gallery({ isProdBuild, latestBuild, isTestBuild }) {
return {
name: "gallery" + nameSuffix(latestBuild),
entry: {
@@ -287,6 +287,7 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isTestBuild,
defineOverlay: {
__DEMO__: true,
},
+1
View File
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
createGalleryConfig({
isProdBuild: true,
latestBuild: true,
isTestBuild: env.isTestBuild(),
})
)
);
+89 -3
View File
@@ -1,4 +1,6 @@
const { existsSync } = require("fs");
const fs = require("fs");
const { existsSync } = fs;
const path = require("path");
const rspack = require("@rspack/core");
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs");
// Build-toolchain packages whose version changes the emitted bytes but which
// are loader/compiler machinery, not modules in the build graph — so rspack's
// node_modules snapshot cannot see them. Their versions are folded into the
// persistent cache `version` so a toolchain upgrade invalidates the cache,
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
const TOOLCHAIN_PACKAGES = [
"@rspack/core",
"@babel/core",
"@babel/preset-env",
"babel-plugin-polyfill-corejs3",
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
"@babel/runtime",
"babel-loader",
"core-js",
"terser",
"terser-webpack-plugin",
"browserslist",
"caniuse-lite",
];
// Our own build logic — the config, loaders and babel plugins. Their contents
// (not their paths) go into the cache version, so a change invalidates the
// cache the same way `buildDependencies` would, but without tying validity to
// absolute paths — rspack compares buildDependencies by path, which breaks a
// cache reused on another machine/checkout (a different workspace path).
const CONFIG_FILES = [
__filename,
path.join(__dirname, "bundle.cjs"),
path.join(__dirname, "minify-template-literals-loader.cjs"),
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
];
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
// stays valid when reused on a different machine or checkout path.
const cacheVersion = () => {
const parts = [
...TOOLCHAIN_PACKAGES.map(
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
),
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
];
return require("crypto")
.createHash("sha256")
.update(parts.join("\n"))
.digest("hex")
.slice(0, 16);
};
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -376,6 +433,33 @@ const createRspackConfig = ({
])
),
},
// Persistent filesystem cache for production builds, opt-in per environment
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
// tests) = no cache.
...(isProdBuild && process.env.RSPACK_CACHE
? {
cache: {
type: "persistent",
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Content-based version (node major + toolchain versions + our own
// build files). Everything is path-independent, so the cache stays
// valid when reused on another machine/checkout. Runtime deps are
// deliberately absent — rspack's node_modules snapshot invalidates
// their modules per-package, so a single unrelated bump keeps the
// rest warm. buildDependencies is intentionally not used: rspack
// compares it by absolute path, which breaks cross-machine reuse.
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
storage: {
type: "filesystem",
directory: path.resolve(paths.root_dir, ".rspack-cache"),
},
// CI reads the nightly-written cache but must not modify it.
readonly: process.env.RSPACK_CACHE === "readonly",
},
}
: {}),
experiments: {
outputModule: true,
},
@@ -405,8 +489,10 @@ const createDemoConfig = ({
const createCastConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
createRspackConfig(
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
+36
View File
@@ -14,6 +14,7 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
@@ -50,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
+3
View File
@@ -87,6 +87,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -111,6 +112,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -135,6 +137,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -50,6 +50,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -74,6 +75,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
@@ -100,6 +100,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "backyard",
@@ -124,6 +125,7 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: null,
@@ -148,6 +150,85 @@ const DEVICES: DeviceRegistryEntry[] = [
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
+79 -1
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,6 +340,84 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
@@ -238,6 +238,7 @@ const createDeviceRegistryEntries = (
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
];
+1 -1
View File
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -2,10 +2,31 @@ import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { HomeAssistant } from "../../../types";
/**
* Return the effective area id of a device: a child device without an area of
* its own inherits its parent's area (mirrors core's
* async_get_effective_area_id). Nesting is single-level, so no recursion.
*/
export const getDeviceAreaId = (
device: DeviceRegistryEntry,
devices: HomeAssistant["devices"]
): string | undefined => {
if (device.area_id) {
return device.area_id;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id ?? undefined;
}
return undefined;
};
export const getDeviceArea = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
// Required so every caller resolves a child device's effective area
// consistently, see getDeviceAreaId.
devices: HomeAssistant["devices"]
): AreaRegistryEntry | undefined => {
const areaId = device.area_id;
const areaId = getDeviceAreaId(device, devices);
return areaId ? areas[areaId] : undefined;
};
@@ -8,6 +8,7 @@ import type {
} from "../../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
@@ -46,7 +47,11 @@ export const getEntityAreaId = (
if (!entry) return undefined;
const deviceId = entry.device_id;
const device = deviceId ? devices[deviceId] : undefined;
return entry.area_id || device?.area_id || undefined;
return (
entry.area_id ||
(device ? getDeviceAreaId(device, devices) : undefined) ||
undefined
);
};
export const getEntityEntryContext = (
@@ -60,7 +65,8 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const areaId = entry?.area_id || device?.area_id;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? floors[floorId] : undefined;
@@ -73,7 +73,7 @@ export class DialogDeviceReplaced
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const area = device ? getDeviceArea(device, areas, devices) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
+104 -39
View File
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -26,7 +28,9 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -242,7 +275,7 @@ export class HaDevicePicker extends LitElement {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
@@ -375,6 +438,8 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+11 -3
View File
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option) => this._renderOption(option)
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
`;
}
private _renderOption(option: ControlSelectOption) {
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${isSelected ? "0" : "-1"}
tabindex=${tabbable ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
@@ -0,0 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
+3 -14
View File
@@ -35,6 +35,7 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
+70 -19
View File
@@ -28,6 +28,7 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -158,6 +159,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -682,9 +684,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "area") {
Object.values(this.hass.devices).forEach((device) => {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
@@ -716,9 +719,18 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id === itemId &&
entity.device_id &&
deviceIds.has(entity.device_id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
@@ -1001,6 +1013,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1008,26 +1042,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
excludeDeviceIds,
replacingDeviceId,
`device${SEPARATOR}`
).sort(this._sortBySortingLabel);
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
deviceComboBoxKeys,
false
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1245,7 +1294,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1265,27 +1316,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
.end=${(item as { last?: boolean }).last}
slot="start"
></ha-tree-indicator>
`
+24 -3
View File
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
</svg>
`;
}
+62
View File
@@ -0,0 +1,62 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-value
* @extends {HaListItemBase}
*
* @summary
* Non-interactive label/value row for grouped lists: label on the start
* side, value content end-aligned. The value is the default slot so callers
* can render rich content (links, secondary lines).
*
* @slot - The value content.
*
* @csspart label - The label column.
* @csspart value - The value column.
*
* @cssprop --ha-list-item-value-max-width - Maximum width of the value column. Defaults to 60%.
*
* @attr {string} label - The row label.
*/
@customElement("ha-list-item-value")
export class HaListItemValue extends HaListItemBase {
@property({ type: String }) public label?: string;
protected override _renderInner(): TemplateResult {
return html`
<div part="label" class="label">${this.label}</div>
<div part="value" class="value"><slot></slot></div>
`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
}
.label {
flex: 1;
color: var(--secondary-text-color);
}
.value {
max-width: var(--ha-list-item-value-max-width, 60%);
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-value": HaListItemValue;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { HaListBase } from "./ha-list-base";
/**
* @element ha-grouped-list
* @extends {HaListBase}
*
* @summary
* Grouped list: an optional header above a framed box of rows separated by
* hairlines the "grouped list" idiom of settings and detail views. Items
* are `<ha-list-item-*>` rows; use `ha-list-item-value` for label/value
* facts and `ha-list-item-button` for navigable rows.
*
* @slot - List items (`<ha-list-item-*>`).
*
* @csspart header - The header above the frame.
* @csspart base - The framed `<div role="list">`.
*
* @cssprop --ha-row-item-padding-inline - Horizontal padding of the rows, which the header aligns to. Defaults to `--ha-space-3`.
*
* @attr {string} header - Header text rendered above the frame.
*/
@customElement("ha-grouped-list")
export class HaGroupedList extends HaListBase {
// The frame carries the list role so the header stays out of the list
// semantics.
protected override readonly hostRole = "";
@property({ type: String }) public header?: string;
protected override render(): TemplateResult {
return html`
${
this.header
? html`<div part="header" class="header" id="header">
${this.header}
</div>`
: nothing
}
<div
part="base"
class="base"
role="list"
aria-labelledby=${ifDefined(this.header ? "header" : undefined)}
>
<slot></slot>
</div>
`;
}
static styles = [
...HaListBase.styles,
css`
:host {
--ha-row-item-padding-inline: var(--ha-space-3);
}
.header {
margin: 0 0 var(--ha-space-1);
margin-inline-start: calc(
var(--ha-row-item-padding-inline) + var(--ha-border-width-sm)
);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.base {
border: var(--ha-border-width-sm) solid var(--divider-color);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
overflow: hidden;
}
::slotted(:not(:first-child)) {
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-grouped-list": HaGroupedList;
}
}
@@ -393,6 +393,9 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -416,6 +419,9 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -424,7 +430,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
@@ -433,7 +439,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
@@ -528,6 +534,12 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -560,6 +572,9 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -585,6 +600,11 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
+51 -11
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${containerOrientationClass}"
class="container ${classMap(containerClasses)}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
</div>
<slot name="features"></slot>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
</div>
`;
}
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.container.horizontal {
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
flex-direction: row;
}
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal ::slotted([slot="features"]) {
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
+177 -76
View File
@@ -117,6 +117,22 @@ const literalTimeToSeconds = (value: unknown): number | undefined => {
return hours * 3600 + minutes * 60 + seconds;
};
const numericThresholdSuffix = (config: {
above?: number | string;
below?: number | string;
}): "above" | "below" | "above_below" | undefined => {
if (config.above !== undefined && config.below !== undefined) {
return "above_below";
}
if (config.above !== undefined) {
return "above";
}
if (config.below !== undefined) {
return "below";
}
return undefined;
};
const formatNumericLimitValue = (
hass: HomeAssistant,
value?: number | string
@@ -130,18 +146,26 @@ const formatNumericLimitValue = (
: value;
};
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Leave the entities out of the sentence, for rows that render them as
// target badges.
hideEntities?: boolean;
}
export const describeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeTrigger(
trigger,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -163,7 +187,7 @@ const tryDescribeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
@@ -179,14 +203,15 @@ const tryDescribeTrigger = (
});
}
if (trigger.alias && !ignoreAlias) {
if (trigger.alias && !options?.ignoreAlias) {
return trigger.alias;
}
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -210,7 +235,8 @@ const tryDescribeTrigger = (
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
// Event Trigger
if (trigger.trigger === "event" && trigger.event_type) {
@@ -241,28 +267,16 @@ const describeLegacyTrigger = (
}
// Numeric State Trigger
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
const entities: string[] = [];
if (
trigger.trigger === "numeric_state" &&
(trigger.entity_id || hideEntities)
) {
const states = hass.states;
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
const attribute = trigger.attribute
? stateObj
? computeAttributeNameDisplay(
@@ -278,6 +292,39 @@ const describeLegacyTrigger = (
? describeDuration(hass.locale, trigger.for)
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(trigger);
if (!suffix) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
{
attribute: attribute,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(trigger.entity_id)) {
for (const entity of trigger.entity_id.values()) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
} else if (trigger.entity_id) {
entities.push(
states[trigger.entity_id]
? computeStateName(states[trigger.entity_id])
: trigger.entity_id
);
}
if (trigger.above !== undefined && trigger.below !== undefined) {
return hass.localize(
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
@@ -319,14 +366,14 @@ const describeLegacyTrigger = (
// State Trigger
if (trigger.trigger === "state") {
const entities: string[] = [];
const states = hass.states;
const entityArray: string[] = ensureArray(trigger.entity_id);
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
let attribute = "";
if (trigger.attribute) {
const stateObj = Array.isArray(trigger.entity_id)
? hass.states[trigger.entity_id[0]]
: (hass.states[trigger.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -337,17 +384,6 @@ const describeLegacyTrigger = (
: trigger.attribute;
}
const entityArray: string[] = ensureArray(trigger.entity_id);
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
const stateObj = hass.states[entityArray[0]] as HassEntity | undefined;
let fromChoice = "other";
let fromString = "";
if (trigger.from !== undefined) {
@@ -427,6 +463,32 @@ const describeLegacyTrigger = (
duration = describeDuration(hass.locale, trigger.for) ?? "";
}
if (hideEntities) {
return hass.localize(
`${triggerTranslationBaseKey}.state.description.changed`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
anyChange: toChoice === "special" ? "true" : "false",
fromChoice: fromChoice,
fromString: fromString,
toChoice: toChoice,
toString: toString,
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (entityArray) {
for (const entity of entityArray) {
if (states[entity]) {
entities.push(computeStateName(states[entity]) || entity);
}
}
}
return hass.localize(
`${triggerTranslationBaseKey}.state.description.full`,
{
@@ -916,14 +978,14 @@ export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
): string => {
try {
const description = tryDescribeCondition(
condition,
hass,
entityRegistry,
ignoreAlias
options
);
if (typeof description !== "string") {
throw new Error(String(description));
@@ -945,7 +1007,7 @@ const tryDescribeCondition = (
condition: Condition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
ignoreAlias = false
options?: DescribeOptions
) => {
if (typeof condition === "string" && hasTemplate(condition)) {
return hass.localize(
@@ -953,7 +1015,7 @@ const tryDescribeCondition = (
);
}
if (condition.alias && !ignoreAlias) {
if (condition.alias && !options?.ignoreAlias) {
return condition.alias;
}
@@ -975,7 +1037,8 @@ const tryDescribeCondition = (
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
entityRegistry,
options?.hideEntities
);
if (description) {
@@ -1001,7 +1064,8 @@ const tryDescribeCondition = (
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
entityRegistry: EntityRegistryEntry[],
hideEntities = false
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1058,17 +1122,20 @@ const describeLegacyCondition = (
// State Condition
if (condition.condition === "state") {
if (!condition.entity_id) {
if (!condition.entity_id && !hideEntities) {
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.no_entity`
);
}
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
let attribute = "";
if (condition.attribute) {
const stateObj = Array.isArray(condition.entity_id)
? hass.states[condition.entity_id[0]]
: (hass.states[condition.entity_id] as HassEntity | undefined);
attribute = stateObj
? computeAttributeNameDisplay(
hass.localize,
@@ -1079,27 +1146,7 @@ const describeLegacyCondition = (
: condition.attribute;
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
const states: string[] = [];
const stateObj = hass.states[
Array.isArray(condition.entity_id)
? condition.entity_id[0]
: condition.entity_id
] as HassEntity | undefined;
if (Array.isArray(condition.state)) {
for (const state of condition.state.values()) {
states.push(
@@ -1116,7 +1163,7 @@ const describeLegacyCondition = (
: state
);
}
} else if (condition.state !== "") {
} else if (condition.state != null && condition.state !== "") {
states.push(
stateObj
? condition.attribute
@@ -1137,6 +1184,37 @@ const describeLegacyCondition = (
duration = describeDuration(hass.locale, condition.for) || "";
}
if (hideEntities) {
if (states.length === 0) {
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.is`,
{
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
states: formatListWithOrs(hass.locale, states),
hasDuration: duration !== "" ? "true" : "false",
duration: duration,
}
);
}
const entities: string[] = [];
if (Array.isArray(condition.entity_id)) {
for (const entity of condition.entity_id.values()) {
if (hass.states[entity]) {
entities.push(computeStateName(hass.states[entity]) || entity);
}
}
} else if (condition.entity_id) {
entities.push(
hass.states[condition.entity_id]
? computeStateName(hass.states[condition.entity_id])
: condition.entity_id
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.state.description.full`,
{
@@ -1159,15 +1237,14 @@ const describeLegacyCondition = (
}
// Numeric State Condition
if (condition.condition === "numeric_state" && condition.entity_id) {
const entity_ids = ensureArray(condition.entity_id);
if (
condition.condition === "numeric_state" &&
(condition.entity_id || hideEntities)
) {
const entity_ids = condition.entity_id
? ensureArray(condition.entity_id)
: [];
const stateObj = hass.states[entity_ids[0]] as HassEntity | undefined;
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
const attribute = condition.attribute
? stateObj
@@ -1180,6 +1257,30 @@ const describeLegacyCondition = (
: condition.attribute
: undefined;
if (hideEntities) {
const suffix = numericThresholdSuffix(condition);
if (!suffix) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.label`
);
}
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
{
attribute,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
const entity = formatListWithAnds(
hass.locale,
entity_ids.map((id) =>
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
)
);
if (condition.above !== undefined && condition.below !== undefined) {
return hass.localize(
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
+157 -22
View File
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
@@ -24,12 +25,17 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -53,7 +63,8 @@ export const computeDeviceAreaLabel = (
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
const area = getDeviceArea(device, areas);
// Pass devices so a child device inherits its parent's area.
const area = getDeviceArea(device, areas, devices);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
@@ -61,11 +72,23 @@ export const computeDeviceAreaLabel = (
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
const viaDeviceArea = viaDevice
? getDeviceArea(viaDevice, areas, devices)
: undefined;
const viaDeviceAreaName = viaDeviceArea
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -74,7 +97,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -102,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -118,6 +149,7 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -125,26 +157,60 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
return devEntities.some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -152,11 +218,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return true;
}
return entities.every(
return devEntities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -170,11 +236,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -189,8 +255,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -219,7 +285,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName } =
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -256,10 +322,79 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
return outputDevices;
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
};
+113 -1
View File
@@ -15,6 +15,13 @@ export {
subscribeDeviceRegistry,
} from "../ws-device_registry";
export type DeviceDisabler =
| "user"
| "integration"
| "config_entry"
// The device's parent device is disabled (child devices only).
| "device";
export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
@@ -33,11 +40,116 @@ export interface DeviceRegistryEntry extends RegistryEntry {
area_id: string | null;
name_by_user: string | null;
entry_type: "service" | null;
disabled_by: "user" | "integration" | "config_entry" | null;
disabled_by: DeviceDisabler | null;
configuration_url: string | null;
primary_config_entry: string | null;
// Set when this device is a child (logical part) of another device.
// null for regular top-level devices.
parent_device_id: string | null;
}
/**
* A child device as it arrives over the wire from
* `config/device_registry/list`. A child is a lightweight logical part of a
* parent device (e.g. an outlet of a power strip); it only carries its own
* fields and inherits the rest from its parent. It is never stored in
* `hass.devices` in this shape {@link resolveChildDevices} turns every child
* into a complete {@link DeviceRegistryEntry} at ingestion, so downstream code
* only ever sees full device entries.
*/
export interface ChildDeviceRegistryEntry extends RegistryEntry {
id: string;
config_entry_id: string;
config_subentry_id: string | null;
identifiers: [string, string][];
name: string | null;
name_by_user: string | null;
labels: string[];
area_id: string | null;
disabled_by: DeviceDisabler | null;
parent_device_id: string;
}
/**
* The raw, mixed list returned by `config/device_registry/list`: full devices
* and stripped children, discriminated by the presence of full-device fields.
*/
export type DeviceRegistryListEntry =
DeviceRegistryEntry | ChildDeviceRegistryEntry;
/** Whether a resolved device entry is a child (logical part) of another device. */
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
+55
View File
@@ -22,6 +22,7 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -36,6 +37,7 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+5 -2
View File
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type { LovelaceViewRawConfig } from "./view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: string;
background?: LovelaceDashboardBackgroundConfig;
views: LovelaceViewRawConfig[];
}
+4 -1
View File
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;
background?: LovelaceDashboardBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+23 -10
View File
@@ -14,6 +14,7 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
);
if (hasMatchingdevice) {
return true;
}
@@ -846,6 +843,8 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1112,6 +1111,11 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1143,6 +1147,15 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
+8 -4
View File
@@ -7,7 +7,10 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import type { DeviceRegistryEntry } from "./device/device_registry";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
if (
areaDevices.some((device) =>
@@ -178,6 +179,9 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
+79 -4
View File
@@ -2,12 +2,87 @@ import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { debounce } from "../common/util/debounce";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type {
ChildDeviceRegistryEntry,
DeviceRegistryEntry,
DeviceRegistryListEntry,
} from "./device/device_registry";
// A full device carries fields that stripped children never do; use one of
// those as the discriminant. This keeps "is a stripped child" decoupled from
// "has a parent", so a hypothetical full-featured sub-device would still be
// treated as a complete entry. We key off `connections` rather than
// `config_entries` because the latter is a deprecated compatibility field core
// plans to drop; `connections` is present on every full device and never on a
// stripped child.
const isChildEntry = (
entry: DeviceRegistryListEntry
): entry is ChildDeviceRegistryEntry => !("connections" in entry);
/**
* Resolve the mixed device list from `config/device_registry/list` into a flat
* list of complete {@link DeviceRegistryEntry} objects.
*
* Children are stripped over the wire and inherit the rest from their parent:
* - config-entry association comes from the child's own `config_entry_id`, so
* children still show up under their integration;
* - hardware/display fields (manufacturer, model, versions, ...) are inherited
* from the parent, since a child is a logical part of the same hardware;
* - identity fields (`connections`, `via_device_id`) are NOT inherited a
* child is not the parent and has no connections of its own.
*
* Nesting is a single level (core rejects a child as another child's parent),
* so no recursion is needed.
*/
export const resolveChildDevices = (
entries: DeviceRegistryListEntry[]
): DeviceRegistryEntry[] => {
const parents = new Map<string, DeviceRegistryEntry>();
for (const entry of entries) {
if (!isChildEntry(entry)) {
parents.set(entry.id, entry);
}
}
return entries.map((entry) => {
if (!isChildEntry(entry)) {
return entry;
}
const parent = parents.get(entry.parent_device_id);
return {
// Structural fields derived from the child's own config entry.
config_entries: [entry.config_entry_id],
config_entries_subentries: {
[entry.config_entry_id]: [entry.config_subentry_id],
},
primary_config_entry: entry.config_entry_id,
// Hardware/display fields inherited from the parent.
manufacturer: parent?.manufacturer ?? null,
model: parent?.model ?? null,
model_id: parent?.model_id ?? null,
sw_version: parent?.sw_version ?? null,
hw_version: parent?.hw_version ?? null,
serial_number: parent?.serial_number ?? null,
entry_type: parent?.entry_type ?? null,
configuration_url: parent?.configuration_url ?? null,
// Identity fields — a child has none of its own.
connections: [],
via_device_id: null,
// The child's own fields (id, name, area_id, labels, identifiers,
// parent_device_id, ...) win over everything above.
...entry,
};
});
};
export const fetchDeviceRegistry = (conn: Connection) =>
conn.sendMessagePromise<DeviceRegistryEntry[]>({
type: "config/device_registry/list",
});
conn
.sendMessagePromise<DeviceRegistryListEntry[]>({
type: "config/device_registry/list",
})
.then(resolveChildDevices);
const subscribeDeviceRegistryUpdates = (
conn: Connection,
+173 -93
View File
@@ -1,16 +1,24 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { HomeAssistant } from "../../types";
import "../../components/ha-yaml-editor";
import { computeDomain } from "../../common/entity/compute_domain";
@@ -26,6 +34,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
href?: string;
}
@customElement("ha-more-info-details")
@@ -40,8 +49,15 @@ class HaMoreInfoDetails extends LitElement {
@state() private _stateObj?: HassEntity;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
this.hass.loadBackendTranslation("title", [this.entry.platform]);
}
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
@@ -54,9 +70,93 @@ class HaMoreInfoDetails extends LitElement {
return nothing;
}
const { stateEntries, attributes, yamlData } = this._getDetailData(
this._stateObj
const {
stateEntries,
attributes,
yamlData: stateYamlData,
} = this._getDetailData(this._stateObj);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
const integrationName = this.entry?.platform
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
});
}
if (area && areaName) {
contextEntries.push({
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
});
}
if (this.entry?.platform && integrationName) {
contextEntries.push({
translationKey: "ui.components.related-items.integration",
value: integrationName,
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
});
}
const entityEntries: DetailEntry[] = [
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
},
];
const yamlData = {
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
entity_id: this.params.entityId,
labels: labelNames,
},
...stateYamlData,
};
return html`
<div class="content">
@@ -69,43 +169,41 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${stateEntries.map(
(entry) =>
html`<div class="data-entry">
<div class="key">
${this.hass.localize(entry.translationKey)}
</div>
<div class="value">${entry.value}</div>
</div>`
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
</div>
</div>
</ha-card>
</section>
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${this._renderAttributes(attributes)}
</div>
</div>
</ha-card>
</section>
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
>
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
>
${this._renderAttributes(attributes)}
</ha-grouped-list>
`
}
</div>
@@ -177,6 +275,20 @@ class HaMoreInfoDetails extends LitElement {
: value;
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
}
private _renderAttributes(attributes: string[]) {
if (attributes.length === 0) {
return html`<div class="empty">
@@ -192,28 +304,25 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</div>
</div>
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</ha-list-item-value>
`
);
}
@@ -247,47 +356,18 @@ class HaMoreInfoDetails extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
.section + .section {
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
a {
color: var(--primary-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-2) 0;
padding: var(--ha-space-3) var(--ha-space-4);
}
`;
}
+4 -3
View File
@@ -60,6 +60,7 @@ import type { HomeAssistant } from "../../types";
import { isIosApp } from "../../util/is_ios";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import "../restart/automation-restart-status";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
import {
@@ -799,9 +800,9 @@ export class QuickBar extends LitElement {
title: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_title`
),
text: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
),
text: html`${this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_description`
)}<br /><br /><automation-restart-status></automation-restart-status>`,
confirmText: this.hass.localize(
`ui.dialogs.restart.${actionItem.action}.confirm_action`
),
@@ -0,0 +1,54 @@
import { LitElement, html } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import { computeDomain } from "../../common/entity/compute_domain";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../../data/context";
const ENTITY_NAME_FORMAT: EntityNameItem[] = [
{ type: "entity" },
{ type: "area" },
] as const;
const ENTITY_NAME_OPTIONS = { separator: STRINGS_SEPARATOR_DOT } as const;
@customElement("automation-restart-status")
class AutomationRestartStatus extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
protected render() {
const automations = Object.values(this._states).filter((s) => {
const domain = computeDomain(s.entity_id);
return (
(domain === "script" || domain === "automation") && s.attributes.current
);
});
return automations.length
? html`${this._i18n.localize("ui.dialogs.restart.interrupt_automations")}
<ul>
${automations.map((a) => html`<li>${this._formatters.formatEntityName(a, ENTITY_NAME_FORMAT, ENTITY_NAME_OPTIONS)}</li>`)}
</ul>`
: html`${this._i18n.localize("ui.dialogs.restart.no_interrupt_automations")}`;
}
}
declare global {
interface HTMLElementTagNameMap {
"automation-restart-status": AutomationRestartStatus;
}
}
+7 -6
View File
@@ -41,6 +41,7 @@ import {
showConfirmationDialog,
} from "../generic/show-dialog-box";
import { showRestartWaitDialog } from "./show-dialog-restart";
import "./automation-restart-status";
@customElement("dialog-restart")
class DialogRestart extends LitElement {
@@ -357,12 +358,12 @@ class DialogRestart extends LitElement {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
text: html`${this.hass.localize(
`ui.dialogs.restart.${action}.confirm_description`
)}${
backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing
}`,
`ui.dialogs.restart.${action}.confirm_description`
)}${
backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing
} <br /><br /><automation-restart-status></automation-restart-status>`,
confirmText: this.hass.localize(
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
),
+69 -11
View File
@@ -6,9 +6,13 @@ import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-analytics";
import "../components/ha-button";
import "../components/ha-spinner";
import "../components/ha-svg-icon";
import type { Analytics } from "../data/analytics";
import { setAnalyticsPreferences } from "../data/analytics";
import {
getAnalyticsDetails,
setAnalyticsPreferences,
} from "../data/analytics";
import { onboardAnalyticsStep } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
@@ -22,9 +26,11 @@ class OnboardingAnalytics extends LitElement {
@state() private _error?: string;
@state() private _analyticsDetails: Analytics = {
preferences: {},
};
// Undefined while we are still waiting for the analytics integration to be
// set up (Home Assistant may still be starting up during onboarding).
@state() private _analyticsDetails?: Analytics;
private _retryTimeout?: number;
protected render(): TemplateResult {
return html`
@@ -40,13 +46,26 @@ class OnboardingAnalytics extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
${
this._analyticsDetails
? html`
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
`
: html`
<div class="loading">
<ha-spinner></ha-spinner>
<p>
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
</p>
</div>
`
}
${this._error ? html`<div class="error">${this._error}</div>` : ""}
<div class="footer">
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
@@ -63,6 +82,35 @@ class OnboardingAnalytics extends LitElement {
this._save(ev);
}
});
this._loadAnalyticsDetails();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._retryTimeout) {
clearTimeout(this._retryTimeout);
this._retryTimeout = undefined;
}
}
private async _loadAnalyticsDetails(): Promise<void> {
try {
// The analytics integration registers its WebSocket commands during
// setup, but only stores its data once the config entry is set up. On a
// fresh install we can reach this step before that happened, so keep
// retrying until it is ready instead of failing on save.
this._analyticsDetails = await getAnalyticsDetails(this.hass);
this._error = undefined;
} catch (err: any) {
if (err.code === "not_found") {
this._retryTimeout = window.setTimeout(
() => this._loadAnalyticsDetails(),
1000
);
return;
}
this._error = err.message;
}
}
private _preferencesChanged(
@@ -76,6 +124,9 @@ class OnboardingAnalytics extends LitElement {
private async _save(ev) {
ev.preventDefault();
if (!this._analyticsDetails) {
return;
}
try {
await setAnalyticsPreferences(
this.hass,
@@ -98,6 +149,13 @@ class OnboardingAnalytics extends LitElement {
.error {
color: var(--error-color);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 0;
}
a {
color: var(--primary-color);
text-decoration: none;
@@ -370,6 +370,7 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -52,7 +52,6 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -64,7 +63,6 @@ import {
type ValidConfig,
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceCondition } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
@@ -76,6 +74,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
@@ -182,12 +182,14 @@ export default class HaAutomationConditionRow extends LitElement {
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;
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
@@ -224,13 +226,15 @@ export default class HaAutomationConditionRow extends LitElement {
}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
describeCondition(this.condition, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
@@ -600,6 +604,30 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -777,7 +805,9 @@ export default class HaAutomationConditionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg, true)
describeCondition(this.condition, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.condition.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -7,6 +7,7 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -429,20 +430,8 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -0,0 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
export const getDeviceTarget = (
deviceId?: string
): HassServiceTarget | undefined =>
deviceId ? { device_id: [deviceId] } : undefined;
@@ -0,0 +1,9 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../../common/array/ensure-array";
export const getEntityTarget = (
entityId?: string | string[]
): HassServiceTarget | undefined => {
const entityIds = entityId ? ensureArray(entityId).filter(Boolean) : [];
return entityIds.length ? { entity_id: entityIds } : undefined;
};
@@ -60,7 +60,6 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import type { TriggerDescriptions } from "../../../../data/trigger";
@@ -74,6 +73,8 @@ import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
@@ -214,11 +215,12 @@ export default class HaAutomationTriggerRow extends LitElement {
"target" in
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;
const hasEntityTarget = type === "state" || type === "numeric_state";
const target = this._getTarget(type, descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const triggerTargetSpec =
type === "platform"
@@ -248,12 +250,16 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`
}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, {
hideEntities: true,
})
)}
${
target !== undefined || (descriptionHasTarget && !this._isNew)
target !== undefined || targetRequired
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
targetRequired,
triggerTargetSpec,
type !== "device"
)
@@ -595,6 +601,27 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
type: string,
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.trigger) {
return this.trigger.target;
}
if (hasEntityTarget && "entity_id" in this.trigger) {
return this._getEntityTarget(this.trigger.entity_id);
}
if (type === "device" && "device_id" in this.trigger) {
return this._getDeviceTarget(this.trigger.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -857,7 +884,9 @@ export default class HaAutomationTriggerRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg, true)
describeTrigger(this.trigger, this.hass, this._entityReg, {
ignoreAlias: true,
})
),
defaultValue: this.trigger.alias,
confirmText: this.hass.localize("ui.common.submit"),
@@ -4,6 +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 { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -422,20 +423,8 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -53,12 +53,11 @@ interface UpdateGroup {
key: string;
title: string;
entities: UpdateEntity[];
showUpdateAll: boolean;
showUpdateButton: boolean;
}
const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
const INTEGRATIONS_KEY = "__integrations__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -215,7 +214,7 @@ class HaConfigSectionUpdates extends LitElement {
${group.title}
</div>
${
group.showUpdateAll
group.showUpdateButton
? html`
<ha-button
appearance="plain"
@@ -224,10 +223,12 @@ class HaConfigSectionUpdates extends LitElement {
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
@click=${this._updateAll}
@click=${this._updateGroup}
>
${this.hass.localize(
"ui.panel.config.updates.update_all"
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
@@ -347,7 +348,7 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private async _updateAll(ev: Event) {
private async _updateGroup(ev: Event) {
const group = (ev.currentTarget as any).group as UpdateGroup;
const entityIds = group.entities
.filter((entity) => !updateIsInstalling(entity))
@@ -413,7 +414,6 @@ class HaConfigSectionUpdates extends LitElement {
const systemEntities: UpdateEntity[] = [];
const appEntities: UpdateEntity[] = [];
const byDomain = new Map<string, UpdateEntity[]>();
const otherIntegrationEntities: UpdateEntity[] = [];
for (const entity of entities) {
if (isSystemUpdate(entity)) {
@@ -422,36 +422,29 @@ class HaConfigSectionUpdates extends LitElement {
}
const domain =
entitySources?.[entity.entity_id]?.domain ??
entityRegistry[entity.entity_id]?.platform;
entityRegistry[entity.entity_id]?.platform ??
"unknown";
if (domain === "hassio") {
appEntities.push(entity);
continue;
}
if (!domain) {
otherIntegrationEntities.push(entity);
continue;
}
if (!byDomain.has(domain)) {
byDomain.set(domain, []);
}
byDomain.get(domain)!.push(entity);
}
const multiInstanceGroups: UpdateGroup[] = [];
const integrationGroups: UpdateGroup[] = [];
byDomain.forEach((entries, domain) => {
if (entries.length >= 2) {
multiInstanceGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateAll: true,
});
} else {
otherIntegrationEntities.push(...entries);
}
integrationGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateButton: true,
});
});
multiInstanceGroups.sort((a, b) =>
integrationGroups.sort((a, b) =>
caseInsensitiveStringCompare(a.title, b.title, language)
);
@@ -462,27 +455,18 @@ class HaConfigSectionUpdates extends LitElement {
key: SYSTEM_KEY,
title: localize("ui.panel.config.updates.group_system"),
entities: systemEntities,
showUpdateAll: false,
showUpdateButton: false,
});
}
groups.push(...multiInstanceGroups);
if (otherIntegrationEntities.length) {
groups.push({
key: INTEGRATIONS_KEY,
title: localize("ui.panel.config.updates.group_integrations"),
entities: otherIntegrationEntities,
showUpdateAll: true,
});
}
groups.push(...integrationGroups);
if (appEntities.length) {
groups.push({
key: APPS_KEY,
title: localize("ui.panel.config.updates.group_apps"),
entities: appEntities,
showUpdateAll: true,
showUpdateButton: true,
});
}
@@ -88,7 +88,7 @@ class HaConfigUpdates extends LitElement {
const areaName =
deviceEntry && deviceEntry.entry_type !== "service"
? getDeviceArea(deviceEntry, this._areas)?.name ||
? getDeviceArea(deviceEntry, this._areas, this._devices)?.name ||
this._localize("ui.panel.config.updates.no_area")
: undefined;
@@ -0,0 +1,168 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
const MAX_VISIBLE_CHILD_DEVICES = 10;
@customElement("ha-device-child-devices-card")
export class HaDeviceChildDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@state() public _showAll = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
private _entityCounts = memoizeOne(
(entities: EntityRegistryEntry[]): Record<string, number> => {
const counts: Record<string, number> = {};
for (const entity of entities) {
if (entity.device_id) {
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
}
}
return counts;
}
);
private _childDevices = memoizeOne(
(
deviceId: string,
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices)
.filter((device) => device.parent_device_id === deviceId)
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected render() {
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
if (childDevices.length === 0) {
return nothing;
}
const entityCounts = this._entityCounts(this._entityReg);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(
childDevice,
this.hass.localize,
this.hass.states
)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
<button class="show-more" @click=${this._toggleShowAll}>
${this.hass.localize(
"ui.panel.config.devices.child_devices.show_more",
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
)}
</button>
`
: ""
}
</ha-card>
`;
}
private _toggleShowAll() {
this._showAll = !this._showAll;
}
static styles = css`
:host {
display: block;
}
.card-header {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
button.show-more {
color: var(--primary-color);
text-align: left;
cursor: pointer;
background: none;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
padding: 16px;
font: inherit;
}
button.show-more:focus {
outline: none;
text-decoration: underline;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
}
}
@@ -93,6 +93,27 @@ export class HaDeviceCard extends LitElement {
`
: ""
}
${
this.device.parent_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.part_of"
)}
<span class="hub"
><a
href="/config/devices/device/${
this.device.parent_device_id
}"
>${this._computeDeviceNameDisplay(
this.device.parent_device_id
)}</a
></span
>
</div>
`
: ""
}
${
this.device.via_device_id
? html`
@@ -75,7 +75,11 @@ export class HaDeviceViaDevicesCard extends LitElement {
? viaDevices
: viaDevices.slice(0, MAX_VISIBLE_VIA_DEVICES)
).map((viaDevice) => {
const area = getDeviceArea(viaDevice, this.hass.areas);
const area = getDeviceArea(
viaDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[viaDevice.id] ?? 0;
const secondary = [
area?.name,
@@ -125,7 +125,10 @@ class DialogDeviceRegistryDetail extends DirtyStateProviderMixin<DeviceFormState
<div class="row">
<ha-switch
.checked=${!this._disabledBy}
.disabled=${this._params.device.disabled_by === "config_entry"}
.disabled=${
this._params.device.disabled_by === "config_entry" ||
this._params.device.disabled_by === "device"
}
@change=${this._disabledByChanged}
>
</ha-switch>
@@ -33,6 +33,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
@@ -106,6 +107,7 @@ import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
@@ -442,7 +444,7 @@ export class HaConfigDevicePage extends LitElement {
const batteryChargingState = batteryChargingEntity
? this.hass.states[batteryChargingEntity.entity_id]
: undefined;
const area = device.area_id ? this.hass.areas[device.area_id] : undefined;
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const deviceInfo: TemplateResult[] = integrations.length
? [
@@ -899,6 +901,10 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-child-devices-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -459,6 +459,15 @@ export class HaConfigDeviceDashboard extends LitElement {
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
// Ids of devices that have at least one child device, so a parent can be
// grouped together with its children.
const deviceIdsWithChildren = new Set<string>();
for (const dev of Object.values(devices)) {
if (dev.parent_device_id) {
deviceIdsWithChildren.add(dev.parent_device_id);
}
}
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -472,6 +481,15 @@ export class HaConfigDeviceDashboard extends LitElement {
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const parentDevice = device.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
// The device that identifies this device's family: its parent for a
// child device, itself for a device that has children.
const familyParentDevice =
parentDevice ??
(deviceIdsWithChildren.has(device.id) ? device : undefined);
const { areaName } = computeDeviceAreaLabel(
device,
this.hass.areas,
@@ -486,9 +504,13 @@ export class HaConfigDeviceDashboard extends LitElement {
);
const floorArea =
getDeviceArea(device, areas) ??
getDeviceArea(device, areas, this.hass.devices) ??
(device.via_device_id && this.hass.devices[device.via_device_id]
? getDeviceArea(this.hass.devices[device.via_device_id], areas)
? getDeviceArea(
this.hass.devices[device.via_device_id],
areas,
this.hass.devices
)
: undefined);
const floorId = floorArea?.floor_id;
const floorName =
@@ -523,6 +545,29 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[parentDevice.id]
)
: "",
// Grouping key that keeps a device with its family: children group
// under their parent's name, a parent groups under its own name, and
// standalone devices stay ungrouped. The name is always computed from
// the family's parent device with the same arguments, so a parent and
// its children can never end up in different groups. Like the area and
// floor columns, this groups on the display name rather than the id,
// because the data table renders the raw group value as its header.
device_family_name: familyParentDevice
? computeDeviceNameDisplay(
familyParentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[familyParentDevice.id]
)
: undefined,
firmware_version: device.sw_version || undefined,
battery_entity: [
this._batteryEntity(device.id, deviceEntityLookup),
@@ -583,6 +628,16 @@ export class HaConfigDeviceDashboard extends LitElement {
flex: 2,
minWidth: "150px",
extraTemplate: (device) => html`
${
device.parent_device_name
? html`<div style="color: var(--secondary-text-color);">
${localize(
"ui.panel.config.devices.data_table.part_of_device",
{ name: device.parent_device_name }
)}
</div>`
: nothing
}
${
device.label_entries.length
? html`
@@ -603,6 +658,19 @@ export class HaConfigDeviceDashboard extends LitElement {
groupable: true,
minWidth: "120px",
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
// Keyed on the family name so grouping/sorting keeps a parent together
// with its children (grouping uses the column key directly). The cell
// only shows the parent name for child devices. Filterable stays on
// even when hidden, so searching a parent's name surfaces its children.
sortable: true,
filterable: true,
groupable: true,
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -280,6 +299,22 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -276,6 +295,22 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -174,7 +176,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -170,7 +172,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -15,6 +14,7 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,20 +73,18 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
primary: name || entityName || deviceName || statisticId,
secondary,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,12 +10,15 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-tree-indicator";
import {
disableConfigEntry,
type ConfigEntry,
@@ -48,12 +51,24 @@ class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
// Rendered indented under its parent device.
@property({ type: Boolean, reflect: true, attribute: "is-child" })
public isChild = false;
// The last child of its parent, so the tree connector draws its end.
@property({ attribute: false }) public isLastChild = false;
protected render() {
const device = this.device;
const entities = this._getEntities();
const area = getDeviceArea(device, this.hass.areas);
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
@@ -65,6 +80,25 @@ class HaConfigEntryDeviceRow extends LitElement {
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
${
this.isChild
? html`<ha-tree-indicator
style=${styleMap({
position: "absolute",
// Span the full row height so consecutive children form one
// continuous line; the elbow sits at the vertical centre.
top: "0",
// Align the connector under the parent device icon; the leading
// space (and thus the icon column) is smaller in narrow mode.
left: rtl ? undefined : this.narrow ? "4px" : "44px",
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${this.isLastChild}
slot="start"
></ha-tree-indicator>`
: nothing
}
<ha-svg-icon
.path=${
device.entry_type === "service"
@@ -351,12 +385,22 @@ class HaConfigEntryDeviceRow extends LitElement {
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
:host([is-child]) ha-md-list-item {
--md-list-item-leading-space: 88px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
:host([narrow][is-child]) ha-md-list-item {
--md-list-item-leading-space: 48px;
}
ha-tree-indicator {
width: 48px;
height: 100%;
}
.vertical-divider {
height: 100%;
width: 1px;
@@ -45,6 +45,7 @@ import {
} from "../../../data/config_entries";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
@@ -479,14 +480,16 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? ownDevices.map(
(device) =>
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)
: nothing
@@ -509,14 +512,16 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${ownDevices.map(
(device) =>
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
`
@@ -15,6 +15,7 @@ import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
@@ -191,14 +192,16 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${devices.map(
(device) =>
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -240,7 +240,7 @@ export class BluetoothNetworkVisualization extends LitElement {
const scannerDevice = this._sourceDevices[scanner.source] as
DeviceRegistryEntry | undefined;
const area = scannerDevice
? getDeviceArea(scannerDevice, this.hass.areas)
? getDeviceArea(scannerDevice, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: scanner.source,
@@ -282,7 +282,7 @@ export class BluetoothNetworkVisualization extends LitElement {
const device = this._sourceDevices[node.address] as
DeviceRegistryEntry | undefined;
const area = device
? getDeviceArea(device, this.hass.areas)
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: node.address,
@@ -350,7 +350,9 @@ export class BluetoothNetworkVisualization extends LitElement {
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
const device = this._sourceDevices[address];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
@@ -64,7 +64,9 @@ export function createZHANetworkChartData(
const haDevice = hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice ? getDeviceArea(haDevice, hass.areas) : undefined;
const area = haDevice
? getDeviceArea(haDevice, hass.areas, hass.devices)
: undefined;
// Create node
nodes.push({
id: device.ieee,
@@ -162,7 +162,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
const haDevice = this.hass.devices[device.device_reg_id] as
DeviceRegistryEntry | undefined;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas)
? getDeviceArea(haDevice, this.hass.areas, this.hass.devices)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
@@ -177,7 +177,11 @@ class DialogZWaveJSRebuildNetworkRoutesDetail extends DialogMixin<ZWaveJSRebuild
) ||
this._i18n.localize("ui.components.device-picker.unnamed_device");
const area = getDeviceArea(device, this._registries.areas);
const area = getDeviceArea(
device,
this._registries.areas,
this._registries.devices
);
const areaName = area ? computeAreaName(area) : undefined;
@@ -183,7 +183,9 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
const { id, name } = data as any;
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
@@ -295,7 +297,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
const device = this._devices[node.node_id] as
DeviceRegistryEntry | undefined;
const area = device
? getDeviceArea(device, this.hass.areas)
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
nodes.push({
id: String(node.node_id),
@@ -1,29 +1,46 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } 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 {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -38,39 +55,59 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
@state() private _submitting = false;
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: suggestions?.icon,
title: suggestions?.title ?? "",
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
}
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
}
public closeDialog(): void {
public closeDialog() {
this._open = false;
}
private _dialogClosed(): void {
private _dialogClosed() {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -82,6 +119,18 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -96,41 +145,43 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
</div>
<ha-dialog-footer slot="footer">
${
@@ -185,6 +236,46 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
${
this._error?.base
? html`<ha-alert alert-type="error">${this._error.base}</ha-alert>`
: nothing
}
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -251,15 +342,16 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
)
: "";
private _valueChanged(ev: CustomEvent) {
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
this._error = undefined;
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
if (ev.detail.value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -268,13 +360,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
};
}
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
} else {
this._data = value;
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -286,36 +395,55 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
: baseSlug,
};
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private async _updateDashboard() {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -339,10 +467,25 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
if (await this._params.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -351,7 +494,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
static get styles(): CSSResultGroup {
return [haStyleDialog, css``];
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
}
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -17,6 +18,8 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
@@ -0,0 +1,33 @@
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeaturePosition,
} from "../types";
export interface CardFeatureLayout {
inline: LovelaceCardFeatureConfig[];
below: LovelaceCardFeatureConfig[];
/** Columns filled by the below features, 0 when there are none */
columns: number;
}
const INLINE_COLUMNS = 2;
export const computeCardFeatureLayout = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): CardFeatureLayout => {
if (position !== "inline") {
return { inline: [], below: features ?? [], columns: 1 };
}
const inline = features?.slice(0, 1) ?? [];
const below = features?.slice(1) ?? [];
return { inline, below, columns: Math.min(below.length, INLINE_COLUMNS) };
};
export const computeCardFeatureRows = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): number => {
const { below, columns } = computeCardFeatureLayout(features, position);
return Math.ceil(below.length / Math.max(columns, 1));
};
@@ -1,5 +1,6 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../../types";
import "./hui-card-feature";
import type {
@@ -32,22 +33,32 @@ export class HuiCardFeatures extends LitElement {
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
@property({ type: Number, reflect: true })
public columns = 1;
protected render() {
if (!this.features) {
return nothing;
}
const lastIndex = this.features.length - 1;
const columns = Math.max(this.columns, 1);
return html`
${this.features.map(
(feature) => html`
${this.features.map((feature, index) => {
const column = index % columns;
return html`
<hui-card-feature
class=${classMap({
divided: column > 0,
wide: column === 0 && index === lastIndex,
})}
.hass=${this.hass}
.context=${this.context}
.color=${this.color}
.feature=${feature}
.position=${this.position}
></hui-card-feature>
`
)}
`;
})}
`;
}
@@ -55,20 +66,38 @@ export class HuiCardFeatures extends LitElement {
:host {
--feature-color: var(--state-icon-color);
--feature-height: 42px;
--feature-columns: 1;
--feature-border-radius: var(
--ha-card-features-border-radius,
var(--ha-border-radius-lg)
);
--feature-button-spacing: 12px;
--feature-column-gap: var(
--ha-card-feature-column-gap,
var(--ha-card-feature-gap, 12px)
);
--feature-divider-inset: var(--ha-card-feature-divider-inset, 0px);
pointer-events: none;
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--ha-card-feature-gap, 12px);
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
box-sizing: border-box;
justify-content: space-evenly;
align-content: space-evenly;
}
:host([columns="2"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide {
grid-column: 1 / -1;
}
/* pull the divider out of the column and into the middle of the gutter */
.divided {
box-sizing: border-box;
margin-inline-start: calc(-1 * var(--feature-divider-inset));
padding-inline-start: var(--feature-divider-inset);
border-inline-start: var(--ha-card-feature-divider, none);
}
`;
}
@@ -1,7 +1,7 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
@@ -202,14 +202,7 @@ class HuiMediaPlayerPlaybackCardFeature
});
}
static styles = [
cardFeatureStyles,
css`
ha-control-button-group {
overflow: hidden;
}
`,
];
static styles = cardFeatureStyles;
}
declare global {
@@ -8,13 +8,13 @@ import type {
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getSuggestedPeriod,
getSummedData,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import type { Statistics } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
@@ -69,13 +69,13 @@ interface ProcessContext {
end: Date;
compareStart?: Date;
untrackedOrder: number;
deviceLabels: Record<string, string>;
}
function processDataSet(
ctx: ProcessContext,
computedStyle: CSSStyleDeclaration,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[],
childMap: Record<string, string[]>,
@@ -167,12 +167,7 @@ function processDataSet(
}
const name =
(source.name ||
getStatisticLabel(
ctx.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
)) +
ctx.deviceLabels[source.stat_consumption] +
(source.stat_consumption in childMap
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
: "");
@@ -351,6 +346,8 @@ export function generateEnergyDevicesDetailGraphData(
const data = energyData.stats;
const compareData = energyData.statsCompare;
const devices = energyData.prefs.device_consumption;
const ctx: ProcessContext = {
hass,
config,
@@ -358,10 +355,13 @@ export function generateEnergyDevicesDetailGraphData(
end,
compareStart,
untrackedOrder,
deviceLabels: computeEnergyDeviceLabels(
hass,
devices,
energyData.statsMetadata
),
};
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
devices.forEach((d) => {
if (d.included_in_stat) {
@@ -425,7 +425,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
compareData,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -468,7 +467,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -16,6 +16,7 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
@@ -91,6 +92,8 @@ export class HuiEnergyDevicesGraphCard
private _compoundStats: string[] = [];
private _deviceLabels: Record<string, string> = {};
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -295,9 +298,8 @@ export class HuiEnergyDevicesGraphCard
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
: "";
return (
(this._data?.prefs.device_consumption.find(
(d) => d.stat_consumption === statisticId
)?.name ||
// The untracked slice is not a statistic, so it has no label.
(this._deviceLabels[statisticId] ||
getStatisticLabel(
this.hass,
statisticId,
@@ -377,6 +379,12 @@ export class HuiEnergyDevicesGraphCard
.map((d) => d.included_in_stat)
.filter(Boolean) as string[];
this._deviceLabels = computeEnergyDeviceLabels(
this.hass,
energyData.prefs.device_consumption,
energyData.statsMetadata
);
const devices = energyData.prefs.device_consumption;
const devicesTotals: Record<string, number> = {};
devices.forEach((device) => {
@@ -8,6 +8,7 @@ import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
energySourcesByType,
getEnergyDataCollection,
getSummedData,
@@ -272,8 +273,14 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatPowerShort,
getEnergyDataCollection,
getPowerFromState,
@@ -278,6 +279,13 @@ class HuiPowerSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -294,7 +302,7 @@ class HuiPowerSankeyCard
initialUntracked: homeNode.value,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
+37 -20
View File
@@ -36,6 +36,10 @@ import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
@@ -151,14 +155,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const displayType = this._config?.display_type || "picture";
const featuresCount = this._config?.features?.length || 0;
const featureRows = this._config ? this._featureRows(this._config) : 0;
return (
1 +
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
(featuresPosition === "inline" ? 0 : featuresCount)
featureRows
);
}
@@ -170,13 +172,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
? this._featurePosition(this._config)
: "bottom";
const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (this._config && featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
const displayType = this._config?.display_type || "picture";
@@ -555,15 +556,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureLayout = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
private _featureRows = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_config") || this._ratio === null) {
@@ -601,7 +600,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const secondary = this._computeSensorsDisplay();
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const features = this._featureLayout(this._config);
const displayType = this._config.display_type || "picture";
@@ -620,8 +619,11 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
/* the picture takes the extra height, so only the compact type reserves a row */
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
displayType === "compact" &&
this.layout === "grid" &&
this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
@@ -710,19 +712,34 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.secondary=${secondary}
></ha-tile-info>
${
features.length > 0
features.inline.length > 0
? html`
<hui-card-features
slot="features"
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.features=${features.inline}
.position=${featurePosition}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
.position=${"bottom"}
></hui-card-features>
`
: nothing
}
</ha-tile-container>
</ha-card>
`;
+32 -23
View File
@@ -22,6 +22,10 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
@@ -107,14 +111,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(this._config?.vertical ? 1 : 0) +
(featuresPosition === "inline" ? 0 : featuresCount)
);
const featureRows = this._config ? this._featureRows(this._config) : 0;
return 1 + (this._config?.vertical ? 1 : 0) + featureRows;
}
public getGridOptions(): LovelaceGridOptions {
@@ -123,12 +121,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
let rows = 1;
const featurePosition = this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (this._config && featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
if (this._config?.vertical) {
@@ -234,15 +231,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureLayout = memoizeOne((config: TileCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
private _featureRows = memoizeOne((config: TileCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
protected render() {
if (!this._config || !this.hass) {
@@ -286,7 +281,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
: undefined;
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const features = this._featureLayout(this._config);
const hasImage = Boolean(imageUrl);
@@ -341,14 +336,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
</ha-tile-info>
${
features.length > 0
features.inline.length > 0
? html`
<hui-card-features
slot="features"
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.features=${features.inline}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
></hui-card-features>
`
: nothing
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
@@ -241,6 +242,13 @@ class HuiWaterFlowSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -257,7 +265,7 @@ class HuiWaterFlowSankeyCard
initialUntracked: effectiveTotalInflow,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -215,8 +216,14 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data!.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -181,7 +181,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
<hui-view-background-editor
.hass=${this.hass}
.config=${this._config}
@view-config-changed=${this._viewConfigChanged}
@background-config-changed=${this._viewConfigChanged}
></hui-view-background-editor>
`;
break;
@@ -2,35 +2,41 @@ import memoizeOne from "memoize-one";
import { LitElement, css, html, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewBackgroundConfig,
} from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
background?: LovelaceDashboardBackgroundConfig;
}
@customElement("hui-view-background-editor")
export class HuiViewBackgroundEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config!: LovelaceViewConfig;
@property({ attribute: false }) public config?: BackgroundConfigTarget;
@state({ attribute: false }) private _resolvedImage?: string;
set config(config: LovelaceViewConfig) {
this._config = config;
}
private _localizeValueCallback = (key: string) =>
this.hass.localize(key as Parameters<LocalizeFunc>[0]);
private _schema = memoizeOne(
(localize: LocalizeFunc, showSettings: boolean) =>
(showSettings: boolean) =>
[
{
name: "image",
@@ -40,7 +46,7 @@ export class HuiViewBackgroundEditor extends LitElement {
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
content_id_helper: this.hass.localize(
"ui.panel.lovelace.editor.card.picture.content_id_helper"
),
},
@@ -123,14 +129,14 @@ export class HuiViewBackgroundEditor extends LitElement {
] as const
);
protected updated(changedProps: PropertyValues) {
protected updated(changedProps: PropertyValues<this>) {
if (
this._config &&
this.config &&
this.hass &&
(changedProps.has("_config") ||
(changedProps.has("config") ||
(changedProps.has("hass") && !changedProps.get("hass")))
) {
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
this.style.setProperty(
"--picture-opacity",
`${(background.opacity ?? 100) / 100}`
@@ -156,7 +162,7 @@ export class HuiViewBackgroundEditor extends LitElement {
return nothing;
}
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
return html`
${
@@ -174,7 +180,7 @@ export class HuiViewBackgroundEditor extends LitElement {
<ha-form
.hass=${this.hass}
.data=${background}
.schema=${this._schema(this.hass.localize, true)}
.schema=${this._schema(true)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
.localizeValue=${this._localizeValueCallback}
@@ -183,7 +189,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _backgroundData = memoizeOne(
(backgroundConfig?: LovelaceViewConfig) => {
(backgroundConfig?: BackgroundConfigTarget) => {
let background = backgroundConfig?.background;
if (typeof background === "string") {
const backgroundUrl = background.match(
@@ -220,12 +226,15 @@ export class HuiViewBackgroundEditor extends LitElement {
}
);
private _valueChanged(ev: CustomEvent): void {
const config = {
...this._config,
background: ev.detail.value,
};
fireEvent(this, "view-config-changed", { config });
private _valueChanged(
ev: HASSDomEvent<{ value: LovelaceViewBackgroundConfig }>
) {
fireEvent(this, "background-config-changed", {
config: {
...(this.config || {}),
background: ev.detail.value,
},
});
}
private _computeLabelCallback = (
@@ -292,4 +301,10 @@ declare global {
interface HTMLElementTagNameMap {
"hui-view-background-editor": HuiViewBackgroundEditor;
}
interface HASSDomEvents {
"background-config-changed": {
config: BackgroundConfigTarget;
};
}
}
@@ -92,7 +92,7 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
@@ -128,6 +128,10 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input {
width: 100%;
}
@@ -99,7 +99,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
</div>
`
: html`
<div class="flex state">
<div class="flex box">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
@@ -136,6 +136,10 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
min-width: 45px;
text-align: end;
}
.box {
flex-grow: 0;
min-width: 45px;
}
ha-input::part(wa-input) {
text-align: end;
direction: ltr !important;
+8
View File
@@ -1081,10 +1081,18 @@ class HUIRoot extends LitElement {
await this.hass.loadFragmentTranslation("config");
const dashboards = await fetchDashboards(this.hass);
const dashboard = dashboards.find((d) => d.url_path === urlPath);
const lovelace = this.lovelace;
const lovelaceConfig =
lovelace && !isStrategyDashboard(lovelace.rawConfig)
? lovelace.rawConfig
: undefined;
showDashboardDetailDialog(this, {
dashboard,
urlPath,
...(lovelace && lovelaceConfig
? { lovelaceConfig, saveConfig: lovelace.saveConfig }
: {}),
updateDashboard: async (values) => {
await updateDashboard(this.hass!, dashboard!.id, values);
},
+39 -16
View File
@@ -702,9 +702,9 @@
"geo_location": "[%key:ui::panel::config::automation::editor::triggers::type::geo_location::label%]",
"homeassistant": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::label%]",
"mqtt": "[%key:ui::panel::config::automation::editor::triggers::type::mqtt::label%]",
"numeric_state": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::label%]",
"numeric_state": "[%key:ui::panel::config::automation::editor::conditions::type::numeric_state::label%]",
"persistent_notification": "[%key:ui::panel::config::automation::editor::triggers::type::persistent_notification::label%]",
"state": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"sun": "[%key:ui::panel::config::automation::editor::triggers::type::sun::label%]",
"tag": "[%key:ui::panel::config::automation::editor::triggers::type::tag::label%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::template::label%]",
@@ -1667,6 +1667,11 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
"raw": "Raw",
@@ -2073,6 +2078,8 @@
"error_backup_state": "An error occurred while getting the current backup state. Error: {error}",
"wait_for_upload": "Wait for backup upload to finish",
"wait_for_restore": "Wait for backup restore to finish",
"interrupt_automations": "The following automations and scripts are currently running and will be interrupted:",
"no_interrupt_automations": "No automations or scripts are currently running.",
"reload": {
"title": "Quick reload",
"description": "Loads new YAML configurations without a restart.",
@@ -2083,14 +2090,14 @@
"title": "Restart Home Assistant",
"description": "Interrupts all running automations and scripts.",
"confirm_title": "Restart Home Assistant?",
"confirm_description": "This will interrupt all running automations and scripts.",
"confirm_description": "All integrations will be reloaded.",
"confirm_action": "Restart",
"confirm_action_backup": "Wait and restart",
"failed": "Failed to restart Home Assistant"
},
"stop": {
"confirm_title": "Stop Home Assistant?",
"confirm_description": "This will interrupt all running automations and scripts.",
"confirm_description": "Home Assistant will be stopped.",
"confirm_action": "Stop"
},
"reboot": {
@@ -2769,7 +2776,6 @@
"caption": "Updates",
"description": "Manage updates of Home Assistant, apps, and devices",
"group_system": "Home Assistant",
"group_integrations": "Integrations",
"group_apps": "Apps",
"update_all": "Update all",
"no_updates": "No updates available",
@@ -5348,7 +5354,7 @@
}
},
"state": {
"label": "State",
"label": "State changed",
"attribute": "Attribute (optional)",
"from": "From (optional)",
"for": "For",
@@ -5356,7 +5362,8 @@
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
"choices": {
@@ -5386,7 +5393,7 @@
}
},
"numeric_state": {
"label": "Numeric state",
"label": "Numeric state crossed threshold",
"above": "Above",
"below": "Below",
"lower_limit": "Lower limit",
@@ -5398,7 +5405,10 @@
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
},
"threshold_type": {
"choices": {
@@ -5642,7 +5652,7 @@
"numeric_state": {
"type_value": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_value%]",
"type_input": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_input%]",
"label": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::label%]",
"label": "Numeric state",
"above": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::above%]",
"below": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::below%]",
"lower_limit": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::lower_limit%]",
@@ -5652,7 +5662,10 @@
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}"
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
}
},
"or": {
@@ -5664,12 +5677,13 @@
}
},
"state": {
"label": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"state": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"label": "State",
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"no_entity": "If state confirmed",
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }"
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
}
},
"sun": {
@@ -6786,7 +6800,8 @@
"disabled_by": {
"user": "user",
"integration": "integration",
"config_entry": "config entry"
"config_entry": "config entry",
"device": "parent device"
},
"enabled_description": "Disabled devices and services will not be shown and entities belonging to them will be disabled, too.",
"open_configuration_url": "Visit",
@@ -6799,6 +6814,10 @@
"heading": "Connected devices",
"show_more": "+{count} devices not shown"
},
"child_devices": {
"heading": "Sub-devices",
"show_more": "+{count} devices not shown"
},
"linked_devices": {
"heading": "Linked devices",
"description": "These devices share hardware with this device and are managed by other integrations."
@@ -6880,6 +6899,8 @@
"manufacturer": "Manufacturer",
"model": "Model",
"integration": "Integration",
"parent_device": "Parent device",
"part_of_device": "Part of {name}",
"firmware_version": "Firmware",
"battery": "Battery",
"disabled_by": "Disabled",
@@ -7172,6 +7193,7 @@
"disable_error": "Enabling or disabling of the integration failed",
"manuf": "by {manufacturer}",
"via": "Connected via",
"part_of": "Part of",
"firmware": "Firmware: {version}",
"hardware": "Hardware: {version}",
"version": "Version {version}",
@@ -10450,7 +10472,7 @@
"bottom": "Bottom",
"bottom_description": "Displays all features stacked",
"inline": "Inline",
"inline_description": "Displays only the first feature"
"inline_description": "Displays features in two columns, starting next to the name"
},
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
"content_layout": "Content layout",
@@ -11483,6 +11505,7 @@
"analytics": {
"header": "Help us help you",
"finish": "Next",
"waiting": "Waiting for Home Assistant to finish starting up…",
"preferences": {
"base": {
"title": "[%key:ui::panel::config::analytics::preferences::base::title%]",
@@ -77,6 +77,7 @@ export const mockDevice = (
disabled_by: null,
configuration_url: null,
primary_config_entry: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
...partial,
@@ -1,43 +1,65 @@
import { describe, expect, it } from "vitest";
import { assert, describe, it } from "vitest";
import { getDeviceArea } from "../../../../src/common/entity/context/get_device_context";
import { mockArea, mockDevice } from "./context-mock";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { HomeAssistant } from "../../../../src/types";
const area = (id: string): AreaRegistryEntry =>
({ area_id: id, name: id }) as AreaRegistryEntry;
const device = (
partial: Partial<DeviceRegistryEntry> & { id: string }
): DeviceRegistryEntry =>
({
area_id: null,
parent_device_id: null,
...partial,
}) as DeviceRegistryEntry;
const AREAS: HomeAssistant["areas"] = {
kitchen: area("kitchen"),
living_room: area("living_room"),
};
describe("getDeviceArea", () => {
it("returns the device's own area", () => {
const dev = device({ id: "d1", area_id: "kitchen" });
assert.strictEqual(getDeviceArea(dev, AREAS, {})?.area_id, "kitchen");
});
it("returns undefined when the device has no area", () => {
const device = mockDevice({
id: "device_1",
});
const result = getDeviceArea(device, {});
expect(result).toBeUndefined();
const dev = device({ id: "d1" });
assert.strictEqual(getDeviceArea(dev, AREAS, {}), undefined);
});
it("returns the area when the device area exists", () => {
const device = mockDevice({
id: "device_2",
area_id: "area_1",
});
const area = mockArea({
area_id: "area_1",
});
const result = getDeviceArea(device, {
area_1: area,
});
expect(result).toEqual(area);
it("inherits the parent's area for a child without its own area", () => {
const parent = device({ id: "parent", area_id: "living_room" });
const child = device({ id: "child", parent_device_id: "parent" });
const devices = { parent, child };
assert.strictEqual(
getDeviceArea(child, AREAS, devices)?.area_id,
"living_room"
);
});
it("returns undefined when the device area is missing", () => {
const device = mockDevice({
id: "device_3",
area_id: "area_2",
it("prefers the child's own area over the parent's", () => {
const parent = device({ id: "parent", area_id: "living_room" });
const child = device({
id: "child",
area_id: "kitchen",
parent_device_id: "parent",
});
const devices = { parent, child };
assert.strictEqual(
getDeviceArea(child, AREAS, devices)?.area_id,
"kitchen"
);
});
const result = getDeviceArea(device, {});
expect(result).toBeUndefined();
it("returns undefined when the parent also has no area", () => {
const parent = device({ id: "parent" });
const child = device({ id: "child", parent_device_id: "parent" });
const devices = { parent, child };
assert.strictEqual(getDeviceArea(child, AREAS, devices), undefined);
});
});
@@ -93,6 +93,62 @@ describe("getEntityContext", () => {
});
});
it("should inherit the parent device's area for an entity on a child device", () => {
const entity = mockEntity({
entity_id: "switch.outlet_1",
device_id: "child_1",
});
const childDevice = mockDevice({
id: "child_1",
parent_device_id: "parent_1",
});
const parentDevice = mockDevice({
id: "parent_1",
area_id: "area_1",
});
const area = mockArea({
area_id: "area_1",
floor_id: "floor_1",
});
const floor = mockFloor({
floor_id: "floor_1",
});
const stateObj = mockStateObj({
entity_id: "switch.outlet_1",
});
const hass = {
entities: {
"switch.outlet_1": entity,
},
devices: {
child_1: childDevice,
parent_1: parentDevice,
},
areas: {
area_1: area,
},
floors: {
floor_1: floor,
},
} as unknown as HomeAssistant;
const result = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toEqual({
entity,
device: childDevice,
area,
floor,
});
});
it("should return the correct context when the entity has an area but no device", () => {
const entity = mockEntity({
entity_id: "sensor.kitchen",
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_MIN_KELVIN } from "../../../src/common/color/convert-light-color";
import { getSelectorFallbackValue } from "../../../src/components/ha-form/get-selector-fallback-value";
describe("getSelectorFallbackValue", () => {
it("returns the constant selector value", () => {
expect(getSelectorFallbackValue({ constant: { value: "fixed" } })).toBe(
"fixed"
);
expect(getSelectorFallbackValue({ constant: { value: 0 } })).toBe(0);
expect(getSelectorFallbackValue({ constant: { value: false } })).toBe(
false
);
});
it("returns false for boolean selectors", () => {
expect(getSelectorFallbackValue({ boolean: null })).toBe(false);
expect(getSelectorFallbackValue({ boolean: {} })).toBe(false);
});
it("returns number min, or 0 when min is omitted", () => {
expect(getSelectorFallbackValue({ number: { min: 2000 } })).toBe(2000);
expect(getSelectorFallbackValue({ number: { min: 0, max: 100 } })).toBe(0);
expect(getSelectorFallbackValue({ number: null })).toBe(0);
});
it("returns kelvin min for color_temp, falling back to DEFAULT_MIN_KELVIN", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "kelvin", min: 2000, max: 6500 },
})
).toBe(2000);
expect(getSelectorFallbackValue({ color_temp: { unit: "kelvin" } })).toBe(
DEFAULT_MIN_KELVIN
);
});
it("returns mired min for color_temp, including legacy min_mireds", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "mired", min: 154, max: 500 },
})
).toBe(154);
expect(getSelectorFallbackValue({ color_temp: { min_mireds: 160 } })).toBe(
160
);
expect(getSelectorFallbackValue({ color_temp: null })).toBe(153);
});
it("returns undefined when the selector has no displayed fallback", () => {
expect(getSelectorFallbackValue({ text: null })).toBeUndefined();
expect(getSelectorFallbackValue({ entity: null })).toBeUndefined();
expect(getSelectorFallbackValue({ target: {} })).toBeUndefined();
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import "../../../src/components/target-picker/ha-target-picker-item-row";
import type { HaTargetPickerItemRow } from "../../../src/components/target-picker/ha-target-picker-item-row";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type {
ExtractFromTargetResult,
TargetType,
} from "../../../src/data/target";
import type { HomeAssistant } from "../../../src/types";
const extractResult = (
referenced: Partial<ExtractFromTargetResult>
): ExtractFromTargetResult => ({
missing_areas: [],
missing_devices: [],
missing_floors: [],
missing_labels: [],
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
...referenced,
});
const mkEntity = (
entity_id: string,
rest: Partial<EntityRegistryDisplayEntry> = {}
): EntityRegistryDisplayEntry => ({ entity_id, labels: [], ...rest });
const mkDevice = (
id: string,
rest: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry =>
({ id, area_id: null, labels: [], ...rest }) as DeviceRegistryEntry;
interface Registries {
entities?: Record<string, EntityRegistryDisplayEntry>;
devices?: Record<string, DeviceRegistryEntry>;
areas?: Record<string, AreaRegistryEntry>;
}
// Runs the row's extraction against `result`, with only the registry entries in
// `registries` available to filter it.
const extractedBy = async (
result: ExtractFromTargetResult,
registries: Registries,
{
type = "area",
itemId = "area_1",
}: { type?: TargetType; itemId?: string } = {}
) => {
const el = document.createElement(
"ha-target-picker-item-row"
) as HaTargetPickerItemRow;
el.type = type;
el.itemId = itemId;
el.hass = {
areas: {},
devices: {},
entities: {},
states: {},
callWS: async () => result,
...registries,
} as unknown as HomeAssistant;
await (el as any)._updateItemData();
return (el as any)._entries as ExtractFromTargetResult | undefined;
};
describe("ha-target-picker-item-row target extraction", () => {
it("drops entities that are missing from the entity registry", async () => {
// Disabled entities are referenced by core but never reach the display
// registry, which is the crash in #52964.
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.known", "light.disabled"],
}),
{
entities: {
"light.known": mkEntity("light.known", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.known"]);
});
it("keeps every entity when all registry entries resolve", async () => {
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.one", "light.two"],
}),
{
entities: {
"light.one": mkEntity("light.one", { area_id: "area_1" }),
"light.two": mkEntity("light.two", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.one", "light.two"]);
});
it("drops a device missing from the registry, and entities linked only through it", async () => {
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_known", "dev_missing"],
referenced_entities: ["light.on_known_dev", "light.on_missing_dev"],
}),
{
devices: { dev_known: mkDevice("dev_known") },
entities: {
"light.on_known_dev": mkEntity("light.on_known_dev", {
device_id: "dev_known",
}),
"light.on_missing_dev": mkEntity("light.on_missing_dev", {
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_known"]);
expect(entries!.referenced_entities).toEqual(["light.on_known_dev"]);
});
it("keeps an entity targeted through its own area when its device is missing", async () => {
// A device we do not know about is not a filter decision, so it must not
// take an entity that the area targets directly down with it.
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_missing"],
referenced_entities: ["light.explicit_area"],
}),
{
entities: {
"light.explicit_area": mkEntity("light.explicit_area", {
area_id: "area_1",
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.explicit_area"]);
});
it("keeps devices of a floor whose area is missing from the registry", async () => {
// Same rule for areas: an area we do not know about must not mark itself
// hidden and drop the devices the floor references through it.
const entries = await extractedBy(
extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_1"],
referenced_entities: ["light.on_dev1"],
}),
{
areas: {},
devices: { dev_1: mkDevice("dev_1", { area_id: "area_missing" }) },
entities: {
"light.on_dev1": mkEntity("light.on_dev1", { device_id: "dev_1" }),
},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
});
@@ -0,0 +1,66 @@
import { assert, describe, it } from "vitest";
import { devicesInEffectiveArea } from "../../src/data/device/device_registry";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const device = (
partial: Partial<DeviceRegistryEntry> & { id: string }
): DeviceRegistryEntry =>
({
area_id: null,
parent_device_id: null,
...partial,
}) as DeviceRegistryEntry;
describe("devicesInEffectiveArea", () => {
it("includes devices with the area set", () => {
const devices = {
a: device({ id: "a", area_id: "kitchen" }),
b: device({ id: "b", area_id: "bedroom" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["a"]
);
});
it("includes a child device inheriting its parent's area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen")
.map((d) => d.id)
.sort(),
["child", "parent"]
);
});
it("excludes a child device with a different explicit area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({
id: "child",
area_id: "bedroom",
parent_device_id: "parent",
}),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["parent"]
);
// ...and the child belongs to its own area instead.
assert.deepEqual(
devicesInEffectiveArea(devices, "bedroom").map((d) => d.id),
["child"]
);
});
it("excludes a child whose parent has no area", () => {
const devices = {
parent: device({ id: "parent" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(devicesInEffectiveArea(devices, "kitchen"), []);
});
});
+154
View File
@@ -13,13 +13,19 @@ import {
} from "../../src/data/translation";
import {
computeConsumptionSingle,
computeEnergyLabel,
computeEnergyDeviceLabels,
formatConsumptionShort,
calculateSolarConsumedGauge,
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
import type { StatisticsMetaData } from "../../src/data/recorder";
import type { HomeAssistant } from "../../src/types";
import { createMockEntityState, createMockHass } from "../fixtures/hass";
const checkConsumptionResult = (
input: {
@@ -944,3 +950,151 @@ describe("getEnergyDefaultPeriodStorageKey", () => {
);
});
});
describe("computeEnergyLabel", () => {
const ENTITY_ID = "sensor.washer_energy";
const createEntry = (
entry: Partial<EntityRegistryDisplayEntry>
): EntityRegistryDisplayEntry =>
({
entity_id: ENTITY_ID,
labels: [],
...entry,
}) as EntityRegistryDisplayEntry;
const createDevice = (
device: Partial<DeviceRegistryEntry>
): DeviceRegistryEntry =>
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
const createHass = (
friendlyName: string,
entry?: Partial<EntityRegistryDisplayEntry>,
device?: Partial<DeviceRegistryEntry>
) =>
createMockHass(
{
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
friendly_name: friendlyName,
}),
},
{
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
devices: device ? { device1: createDevice(device) } : {},
}
);
it("composes the device and entity name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the device name alone when the entity has no name of its own", () => {
const hass = createHass(
"Washer",
{ name: "Washer", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
});
it("distinguishes entities sharing a name by their device", () => {
const hass = createHass(
"Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Dishwasher" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
});
it("keeps a name set by the user", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
"Laundry"
);
});
it("ignores an empty name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
"Washer Energy"
);
});
it("falls back to the friendly name for an entity outside the registry", () => {
const hass = createHass("Washer Energy");
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the statistic metadata name when there is no entity", () => {
const hass = createMockHass();
assert.equal(
computeEnergyLabel(hass, "external:solar", {
statistic_id: "external:solar",
name: "Solar production",
} as StatisticsMetaData),
"Solar production"
);
});
it("falls back to the statistic id when there is nothing to name it with", () => {
const hass = createMockHass();
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
});
});
describe("computeEnergyDeviceLabels", () => {
const DEVICES = [
{
stat_consumption: "sensor.washer_energy",
stat_rate: "sensor.washer_power",
},
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
];
const hass = createMockHass({
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
friendly_name: "Washer Energy",
}),
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
friendly_name: "Washer Power",
}),
});
it("keys labels by the consumption statistic", () => {
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
"sensor.washer_energy": "Washer Energy",
"sensor.heater_energy": "Heater",
});
});
it("keys labels by the rate statistic, skipping devices without one", () => {
assert.deepEqual(
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
{ "sensor.washer_power": "Washer Power" }
);
});
});
@@ -0,0 +1,155 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import type { Condition, Trigger } from "../../src/data/automation";
import {
describeCondition,
describeTrigger,
} from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {
"light.kitchen": {
entity_id: "light.kitchen",
state: "on",
attributes: { friendly_name: "Kitchen light" },
},
"sensor.temperature": {
entity_id: "sensor.temperature",
state: "21",
attributes: { friendly_name: "Temperature" },
},
},
entities: {},
formatEntityState: (_stateObj, state?: string) => state ?? "",
} as unknown as HomeAssistant;
const describeRowTrigger = (trigger: Trigger) =>
describeTrigger(trigger, hass, [], { hideEntities: true });
const describeRowCondition = (condition: Condition) =>
describeCondition(condition, hass, [], { hideEntities: true });
describe("describing state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "state",
entity_id: "light.kitchen",
to: "on",
};
const condition: Condition = {
condition: "state",
entity_id: "light.kitchen",
state: "on",
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Kitchen light changes to on"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Kitchen light is on"
);
});
it("leaves the entities out when they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("State changed to on");
expect(describeRowCondition(condition)).toBe("State is on");
});
it("falls back to the label when nothing is configured yet", () => {
expect(
describeRowTrigger({ trigger: "state", entity_id: "light.kitchen" })
).toBe("State or any attribute changed");
expect(
describeRowCondition({
condition: "state",
entity_id: "light.kitchen",
state: [],
})
).toBe("State");
});
});
describe("describing numeric state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
const condition: Condition = {
condition: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
it("names the entities by default", () => {
expect(describeTrigger(trigger, hass, [])).toBe(
"When Temperature is above 20"
);
expect(describeCondition(condition, hass, [])).toBe(
"If Temperature is above 20"
);
});
it("leaves the entities out when they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("Numeric state crossed above 20");
expect(describeRowCondition(condition)).toBe("Numeric state is above 20");
});
it("describes both thresholds", () => {
expect(describeRowTrigger({ ...trigger, below: 30 })).toBe(
"Numeric state crossed above 20 and below 30"
);
expect(describeRowCondition({ ...condition, below: 30 })).toBe(
"Numeric state is above 20 and below 30"
);
});
it("falls back to the label without a threshold", () => {
expect(
describeRowTrigger({
trigger: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state crossed threshold");
expect(
describeRowCondition({
condition: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state");
});
});
+110
View File
@@ -0,0 +1,110 @@
import { assert, describe, it } from "vitest";
import { resolveChildDevices } from "../../src/data/ws-device_registry";
import type {
ChildDeviceRegistryEntry,
DeviceRegistryEntry,
} from "../../src/data/device/device_registry";
const parent: DeviceRegistryEntry = {
id: "parent",
config_entries: ["entry-1"],
config_entries_subentries: { "entry-1": [null] },
connections: [["mac", "aa:bb:cc:dd:ee:ff"]],
identifiers: [["hue", "strip-1"]],
manufacturer: "Acme",
model: "Power Strip",
model_id: "PS-1",
name: "Power strip",
labels: ["strip"],
sw_version: "1.0",
hw_version: "2.0",
serial_number: "SN-1",
via_device_id: "bridge",
area_id: "living_room",
name_by_user: null,
entry_type: null,
disabled_by: null,
configuration_url: "http://strip.local",
primary_config_entry: "entry-1",
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
const child: ChildDeviceRegistryEntry = {
id: "child",
config_entry_id: "entry-1",
config_subentry_id: "sub-1",
identifiers: [["hue", "outlet-1"]],
name: "Outlet 1",
name_by_user: "Coffee machine",
labels: ["outlet"],
area_id: "kitchen",
disabled_by: null,
parent_device_id: "parent",
created_at: 5,
modified_at: 6,
};
describe("resolveChildDevices", () => {
it("leaves full devices untouched", () => {
const [resolved] = resolveChildDevices([parent]);
assert.strictEqual(resolved, parent);
});
it("resolves a child into a complete device entry", () => {
const result = resolveChildDevices([parent, child]);
const resolved = result.find((d) => d.id === "child")!;
// Config-entry association comes from the child's own config entry.
assert.deepEqual(resolved.config_entries, ["entry-1"]);
assert.deepEqual(resolved.config_entries_subentries, {
"entry-1": ["sub-1"],
});
assert.strictEqual(resolved.primary_config_entry, "entry-1");
// Hardware/display fields are inherited from the parent.
assert.strictEqual(resolved.manufacturer, "Acme");
assert.strictEqual(resolved.model, "Power Strip");
assert.strictEqual(resolved.model_id, "PS-1");
assert.strictEqual(resolved.sw_version, "1.0");
assert.strictEqual(resolved.hw_version, "2.0");
assert.strictEqual(resolved.serial_number, "SN-1");
assert.strictEqual(resolved.configuration_url, "http://strip.local");
assert.strictEqual(resolved.entry_type, null);
// Identity fields are NOT inherited — a child is not the parent.
assert.deepEqual(resolved.connections, []);
assert.strictEqual(resolved.via_device_id, null);
// The child's own fields win.
assert.strictEqual(resolved.id, "child");
assert.strictEqual(resolved.name, "Outlet 1");
assert.strictEqual(resolved.name_by_user, "Coffee machine");
assert.strictEqual(resolved.area_id, "kitchen");
assert.deepEqual(resolved.labels, ["outlet"]);
assert.deepEqual(resolved.identifiers, [["hue", "outlet-1"]]);
assert.strictEqual(resolved.parent_device_id, "parent");
assert.strictEqual(resolved.created_at, 5);
assert.strictEqual(resolved.modified_at, 6);
});
it("falls back to null display fields when the parent is missing", () => {
const [resolved] = resolveChildDevices([child]);
assert.deepEqual(resolved.config_entries, ["entry-1"]);
assert.strictEqual(resolved.manufacturer, null);
assert.strictEqual(resolved.model, null);
assert.deepEqual(resolved.connections, []);
assert.strictEqual(resolved.via_device_id, null);
assert.strictEqual(resolved.parent_device_id, "parent");
});
it("preserves ordering of the mixed list", () => {
const result = resolveChildDevices([child, parent]);
assert.deepEqual(
result.map((d) => d.id),
["child", "parent"]
);
});
});
+1
View File
@@ -68,6 +68,7 @@ const subscriptionResults: Record<string, unknown> = {
};
const commandResults: Record<string, unknown> = {
analytics: { preferences: {} },
"analytics/preferences": {},
"auth/current_user": currentUser,
"brands/access_token": { token: "brands-token" },
+2 -2
View File
@@ -77,8 +77,8 @@ export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
// The details view renders the state and attributes grouped lists.
content: [{ selector: "ha-grouped-list" }],
},
];

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