mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-14 10:27:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f23c8f0e23 | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 | ||
|
|
22ca986cce | ||
|
|
7f8bf69424 | ||
|
|
92224411e1 | ||
|
|
b52d58eccb | ||
|
|
a9cc47888a | ||
|
|
ea0ceecbfc | ||
|
|
5f007a1575 | ||
|
|
f360a22927 | ||
|
|
a67111e41f | ||
|
|
4b7d3a7e4f | ||
|
|
88be7adafa |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
|
||||
createGalleryConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"}`
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
+5
-3
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"), []);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user